From 8ec480c9bd13165601697bb4d83940afef308077 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Tue, 25 Nov 2025 20:58:09 -0500 Subject: [PATCH 1/5] refactor: improvements for better transport integration --- src/dotnet/PloyBus.Tests/PloyBus.Tests.csproj | 1 + src/dotnet/PloyBus.Tests/PolyBusTests.cs | 198 --------- .../Transport/InMemory/AlphaCommand.cs | 9 + .../Transport/InMemory/AlphaEvent.cs | 9 + .../Transport/InMemory/InMemoryTests.cs | 63 --- .../InMemory/InMemoryTransportTests.cs | 234 ++++++++++ .../Transport/InMemory/TestContextLogger.cs | 24 ++ .../InMemory/TestContextLoggerProvider.cs | 14 + .../Transport/InMemory/TestEndpoint.cs | 18 + .../Transport/InMemory/TestEnvironment.cs | 56 +++ .../Transactions/Messages/Command.cs | 4 + .../Transport/Transactions/Messages/Event.cs | 4 + .../Handlers/Error/ErrorHandlerTestMessage.cs | 4 + .../Handlers/Error/ErrorHandlerTests.cs | 110 ++--- .../Error/ExceptionWithNullStackTrace.cs | 7 + .../Messages/Handlers/Error/TestBus.cs | 18 + .../Messages/Handlers/Error/TestTransport.cs | 16 + .../Serializers/JsonHandlerTestMessage.cs | 7 + .../Handlers/Serializers/JsonHandlersTests.cs | 407 +----------------- .../Transactions/Messages/MessageInfoTests.cs | 66 +-- .../Messages/MessageWithoutAttribute.cs | 3 + .../Transactions/Messages/MessagesTests.cs | 219 +++------- src/dotnet/PolyBus/Headers.cs | 10 + src/dotnet/PolyBus/IPolyBus.cs | 8 +- src/dotnet/PolyBus/PolyBus.cs | 17 +- src/dotnet/PolyBus/PolyBusBuilder.cs | 29 +- src/dotnet/PolyBus/PolyBusError.cs | 6 + src/dotnet/PolyBus/Transport/ITransport.cs | 26 +- .../Transport/InMemory/InMemoryEndpoint.cs | 83 ++++ .../InMemory/InMemoryMessageBroker.cs | 139 ++++++ .../Transport/InMemory/InMemoryTransport.cs | 159 ------- .../Transport/PolyBusNotStartedError.cs | 7 + ...ctory.cs => IncomingTransactionFactory.cs} | 2 +- .../Messages/Handlers/Error/ErrorHandler.cs | 93 +++- .../Handlers/Serializers/JsonHandlers.cs | 53 +-- .../Transactions/Messages/IncomingMessage.cs | 11 +- .../Transactions/Messages/Message.cs | 2 +- .../Transactions/Messages/Messages.cs | 67 +-- .../Transactions/Messages/OutgoingMessage.cs | 19 +- .../Messages/PolyBusMessageNotFoundError.cs | 7 + .../OutgoingTransactionFactory.cs | 8 + .../Transport/Transactions/Transaction.cs | 10 +- 42 files changed, 1012 insertions(+), 1235 deletions(-) delete mode 100644 src/dotnet/PloyBus.Tests/PolyBusTests.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/InMemory/AlphaCommand.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/InMemory/AlphaEvent.cs delete mode 100644 src/dotnet/PloyBus.Tests/Transport/InMemory/InMemoryTests.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/InMemory/InMemoryTransportTests.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/InMemory/TestContextLogger.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/InMemory/TestContextLoggerProvider.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/InMemory/TestEndpoint.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/InMemory/TestEnvironment.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Command.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Event.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/ErrorHandlerTestMessage.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/ExceptionWithNullStackTrace.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/TestBus.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/TestTransport.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Serializers/JsonHandlerTestMessage.cs create mode 100644 src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/MessageWithoutAttribute.cs create mode 100644 src/dotnet/PolyBus/PolyBusError.cs create mode 100644 src/dotnet/PolyBus/Transport/InMemory/InMemoryEndpoint.cs create mode 100644 src/dotnet/PolyBus/Transport/InMemory/InMemoryMessageBroker.cs delete mode 100644 src/dotnet/PolyBus/Transport/InMemory/InMemoryTransport.cs create mode 100644 src/dotnet/PolyBus/Transport/PolyBusNotStartedError.cs rename src/dotnet/PolyBus/Transport/Transactions/{TransactionFactory.cs => IncomingTransactionFactory.cs} (71%) create mode 100644 src/dotnet/PolyBus/Transport/Transactions/Messages/PolyBusMessageNotFoundError.cs create mode 100644 src/dotnet/PolyBus/Transport/Transactions/OutgoingTransactionFactory.cs diff --git a/src/dotnet/PloyBus.Tests/PloyBus.Tests.csproj b/src/dotnet/PloyBus.Tests/PloyBus.Tests.csproj index 437024f..86a2c38 100644 --- a/src/dotnet/PloyBus.Tests/PloyBus.Tests.csproj +++ b/src/dotnet/PloyBus.Tests/PloyBus.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/src/dotnet/PloyBus.Tests/PolyBusTests.cs b/src/dotnet/PloyBus.Tests/PolyBusTests.cs deleted file mode 100644 index 01131fc..0000000 --- a/src/dotnet/PloyBus.Tests/PolyBusTests.cs +++ /dev/null @@ -1,198 +0,0 @@ -using NUnit.Framework; -using PolyBus.Transport.Transactions; - -namespace PolyBus; - -[TestFixture] -public class PolyBusTests -{ - [Test] - public async Task IncomingHandlers_IsInvoked() - { - // Arrange - var incomingTransactionTask = new TaskCompletionSource(); - var builder = new PolyBusBuilder - { - IncomingHandlers = - { - async (transaction, next) => - { - await next(); - incomingTransactionTask.SetResult(transaction); - } - } - }; - var bus = await builder.Build(); - - // Act - await bus.Start(); - var outgoingTransaction = await bus.CreateTransaction(); - outgoingTransaction.AddOutgoingMessage("Hello world", "unknown-endpoint"); - await outgoingTransaction.Commit(); - await bus.Start(); - var transaction = await incomingTransactionTask.Task; - await Task.Yield(); - await bus.Stop(); - - //Assert - Assert.That(transaction.IncomingMessage.Body, Is.EqualTo("Hello world")); - } - - [Test] - public async Task IncomingHandlers_WithDelay_IsInvoked() - { - // Arrange - var processedOnTask = new TaskCompletionSource(); - var builder = new PolyBusBuilder - { - IncomingHandlers = - { - async (_, next) => - { - await next(); - processedOnTask.SetResult(DateTime.UtcNow); - } - } - }; - var bus = await builder.Build(); - - // Act - await bus.Start(); - var outgoingTransaction = await bus.CreateTransaction(); - var message = outgoingTransaction.AddOutgoingMessage("Hello world", "unknown-endpoint"); - var scheduledAt = DateTime.UtcNow.AddSeconds(5); - message.DeliverAt = scheduledAt; - await outgoingTransaction.Commit(); - await bus.Start(); - var processedOn = await processedOnTask.Task; - await Task.Yield(); - await bus.Stop(); - - //Assert - Assert.That(processedOn, Is.GreaterThanOrEqualTo(scheduledAt)); - } - - [Test] - public async Task IncomingHandlers_WithDelayAndException_IsInvoked() - { - // Arrange - var processedOnTask = new TaskCompletionSource(); - var builder = new PolyBusBuilder - { - IncomingHandlers = - { - (transaction, next) => - { - processedOnTask.SetResult(DateTime.UtcNow); - throw new Exception(transaction.IncomingMessage.Body); - } - } - }; - var bus = await builder.Build(); - - // Act - await bus.Start(); - var outgoingTransaction = await bus.CreateTransaction(); - var message = outgoingTransaction.AddOutgoingMessage("Hello world", "unknown-endpoint"); - var scheduledAt = DateTime.UtcNow.AddSeconds(5); - message.DeliverAt = scheduledAt; - await outgoingTransaction.Commit(); - await bus.Start(); - var processedOn = await processedOnTask.Task; - await Task.Yield(); - await bus.Stop(); - - //Assert - Assert.That(processedOn.AddSeconds(1), Is.GreaterThanOrEqualTo(scheduledAt)); - } - - [Test] - public async Task IncomingHandlers_WithException_IsInvoked() - { - // Arrange - var incomingTransactionTask = new TaskCompletionSource(); - var builder = new PolyBusBuilder - { - IncomingHandlers = - { - (transaction, _) => - { - incomingTransactionTask.SetResult(transaction); - throw new Exception(transaction.IncomingMessage.Body); - } - } - }; - var bus = await builder.Build(); - - // Act - await bus.Start(); - var outgoingTransaction = await bus.CreateTransaction(); - outgoingTransaction.AddOutgoingMessage("Hello world", "unknown-endpoint"); - await outgoingTransaction.Commit(); - await bus.Start(); - var transaction = await incomingTransactionTask.Task; - await Task.Yield(); - await bus.Stop(); - - //Assert - Assert.That(transaction.IncomingMessage.Body, Is.EqualTo("Hello world")); - } - - [Test] - public async Task OutgoingHandlers_IsInvoked() - { - // Arrange - var outgoingTransactionTask = new TaskCompletionSource(); - var builder = new PolyBusBuilder - { - OutgoingHandlers = - { - async (transaction, next) => - { - await next(); - outgoingTransactionTask.SetResult(transaction); - } - } - }; - var bus = await builder.Build(); - - // Act - await bus.Start(); - var outgoingTransaction = await bus.CreateTransaction(); - outgoingTransaction.AddOutgoingMessage("Hello world", "unknown-endpoint"); - await outgoingTransaction.Commit(); - await bus.Start(); - var transaction = await outgoingTransactionTask.Task; - await Task.Yield(); - await bus.Stop(); - - //Assert - Assert.That(transaction.OutgoingMessages.Count, Is.EqualTo(1)); - Assert.That(transaction.OutgoingMessages[0].Body, Is.EqualTo("Hello world")); - } - - [Test] - public async Task OutgoingHandlers_WithException_IsInvoked() - { - // Arrange - var builder = new PolyBusBuilder - { - OutgoingHandlers = - { - (transaction, _) => throw new Exception(transaction.OutgoingMessages[0].Body) - } - }; - var bus = await builder.Build(); - - // Act - await bus.Start(); - var outgoingTransaction = await bus.CreateTransaction(); - outgoingTransaction.AddOutgoingMessage("Hello world", "unknown-endpoint"); - var ex = Assert.ThrowsAsync(outgoingTransaction.Commit); - await bus.Start(); - await bus.Stop(); - - //Assert - Assert.That(ex.Message, Is.EqualTo("Hello world")); - } -} diff --git a/src/dotnet/PloyBus.Tests/Transport/InMemory/AlphaCommand.cs b/src/dotnet/PloyBus.Tests/Transport/InMemory/AlphaCommand.cs new file mode 100644 index 0000000..dacbe46 --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/InMemory/AlphaCommand.cs @@ -0,0 +1,9 @@ +using PolyBus.Transport.Transactions.Messages; + +namespace PolyBus.Transport.InMemory; + +[MessageInfo(MessageType.Command, "alpha", "alpha-command", 1, 0, 0)] +class AlphaCommand +{ + public required string Name { get; set; } +} diff --git a/src/dotnet/PloyBus.Tests/Transport/InMemory/AlphaEvent.cs b/src/dotnet/PloyBus.Tests/Transport/InMemory/AlphaEvent.cs new file mode 100644 index 0000000..d61cd22 --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/InMemory/AlphaEvent.cs @@ -0,0 +1,9 @@ +using PolyBus.Transport.Transactions.Messages; + +namespace PolyBus.Transport.InMemory; + +[MessageInfo(MessageType.Event, "alpha", "alpha-event", 1, 0, 0)] +class AlphaEvent +{ + public required string Name { get; set; } +} diff --git a/src/dotnet/PloyBus.Tests/Transport/InMemory/InMemoryTests.cs b/src/dotnet/PloyBus.Tests/Transport/InMemory/InMemoryTests.cs deleted file mode 100644 index f323b26..0000000 --- a/src/dotnet/PloyBus.Tests/Transport/InMemory/InMemoryTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Reflection; -using NUnit.Framework; -using PolyBus.Transport.Transactions; -using PolyBus.Transport.Transactions.Messages; - -namespace PolyBus.Transport.InMemory; - -[TestFixture] -public class InMemoryTests -{ - private readonly MessageInfo _messageInfo = typeof(TestMessage).GetCustomAttribute()!; - - [Test] - public async Task InMemory_WithSubscription() - { - // Arrange - var inMemoryTransport = new InMemoryTransport - { - UseSubscriptions = true - }; - var incomingTransactionTask = new TaskCompletionSource(); - var builder = new PolyBusBuilder - { - IncomingHandlers = - { - async (transaction, next) => - { - incomingTransactionTask.SetResult(transaction); - await next(); - } - }, - TransportFactory = (builder, bus) => Task.FromResult(inMemoryTransport.AddEndpoint(builder, bus)) - }; - builder.Messages.Add(typeof(TestMessage)); - var bus = await builder.Build(); - await bus.Transport.Subscribe(typeof(TestMessage).GetCustomAttribute()!); - - // Act - await bus.Start(); - var outgoingTransaction = await bus.CreateTransaction(); - var outgoingMessage = outgoingTransaction.AddOutgoingMessage(new TestMessage - { - Name = "TestMessage" - }); - outgoingMessage.Headers[Headers.MessageType] = _messageInfo.ToString(true); - await outgoingTransaction.Commit(); - await bus.Start(); - var incomingTransaction = await incomingTransactionTask.Task; - await Task.Yield(); - await bus.Stop(); - - //Assert - Assert.That(incomingTransaction.IncomingMessage.Body, Is.EqualTo("TestMessage")); - Assert.That(incomingTransaction.IncomingMessage.Headers[Headers.MessageType], Is.EqualTo(_messageInfo.ToString(true))); - } - - [MessageInfo(MessageType.Command, "test-service", "TestMessage", 1, 0, 0)] - public class TestMessage - { - public override string ToString() => Name; - public required string Name { get; init; } = string.Empty; - } -} diff --git a/src/dotnet/PloyBus.Tests/Transport/InMemory/InMemoryTransportTests.cs b/src/dotnet/PloyBus.Tests/Transport/InMemory/InMemoryTransportTests.cs new file mode 100644 index 0000000..a090546 --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/InMemory/InMemoryTransportTests.cs @@ -0,0 +1,234 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using NUnit.Framework; + +namespace PolyBus.Transport.InMemory; + +[TestFixture] +class InMemoryTransportTests +{ + private TestEnvironment _testEnvironment; + + [SetUp] + public async Task SetUp() + { + _testEnvironment = new(); + await _testEnvironment.Setup(); + } + + [TearDown] + public async Task TearDown() + { + await _testEnvironment.Stop(); + } + + [Test, CancelAfter(5 * 60 * 1000)] + public async Task Send_BeforeStarting() + { + // Arrange + var transaction = await _testEnvironment.Beta.Bus.CreateOutgoingTransaction(); + var taskCompletionSource = new TaskCompletionSource(); + _testEnvironment.Alpha.OnMessageReceived = _ => + { + // This should not be called + taskCompletionSource.SetResult(true); + return Task.CompletedTask; + }; + + // Act + transaction.Add(new AlphaCommand { Name = "Test" }); + + // Assert - should throw an error because the transport is not started + Assert.ThrowsAsync(transaction.Commit); + Assert.That(taskCompletionSource.Task.IsCompleted, Is.False); + } + + [Test, CancelAfter(5 * 60 * 1000)] + public async Task Send_AfterStarted() + { + // Arrange + var transaction = await _testEnvironment.Beta.Bus.CreateOutgoingTransaction(); + var taskCompletionSource = new TaskCompletionSource(); + _testEnvironment.Alpha.OnMessageReceived = _ => + { + taskCompletionSource.SetResult(true); + return Task.CompletedTask; + }; + + // Act - send a command from the beta endpoint to alpha endpoint + await _testEnvironment.Start(); + transaction.Add(new AlphaCommand { Name = "Test" }); + await transaction.Commit(); + await taskCompletionSource.Task; + + // Assert + Assert.That(taskCompletionSource.Task.IsCompleted, Is.True); + } + + [Test, CancelAfter(5 * 60 * 1000)] + public async Task Send_WithExplicitEndpoint() + { + // Arrange + var transaction = await _testEnvironment.Alpha.Bus.CreateOutgoingTransaction(); + var taskCompletionSource = new TaskCompletionSource(); + _testEnvironment.Alpha.OnMessageReceived = arg => + { + // This should NOT be called + taskCompletionSource.SetResult(arg.Bus.Name); + return Task.CompletedTask; + }; + _testEnvironment.Alpha.Transport.DeadLetterHandler = _ => + { + // This should be called + taskCompletionSource.SetResult(_testEnvironment.Alpha.Transport.DeadLetterEndpoint); + }; + var endpoint = _testEnvironment.Alpha.Transport.DeadLetterEndpoint; + + // Act - send the alpha command to beta endpoint + await _testEnvironment.Start(); + transaction.Add(new AlphaCommand { Name = "Test" }, endpoint: endpoint); + await transaction.Commit(); + var actualEndpoint = await taskCompletionSource.Task; + + // Assert + Assert.That(actualEndpoint, Is.EqualTo(endpoint)); + } + + [Test, CancelAfter(5 * 60 * 1000)] + public async Task Send_WithHeaders() + { + // Arrange + const string headerKey = "X-Custom-Header"; + const string headerValue = "HeaderValue"; + var transaction = await _testEnvironment.Alpha.Bus.CreateOutgoingTransaction(); + var taskCompletionSource = new TaskCompletionSource(); + _testEnvironment.Alpha.OnMessageReceived = message => + { + taskCompletionSource.SetResult( + message.IncomingMessage.Headers.TryGetValue(headerKey, out var value) + ? value + : string.Empty); + return Task.CompletedTask; + }; + + // Act - send a command with a custom header + await _testEnvironment.Start(); + var message = transaction.Add(new AlphaCommand { Name = "Test" }); + message.Headers.Add(headerKey, headerValue); + await transaction.Commit(); + var actualHeaderValue = await taskCompletionSource.Task; + + // Assert + Assert.That(actualHeaderValue, Is.EqualTo(headerValue)); + } + + [Test, CancelAfter(5 * 60 * 1000)] + public async Task Send_WithDelay() + { + // Arrange + const int delay = 5000; // 5 seconds + var transaction = await _testEnvironment.Alpha.Bus.CreateOutgoingTransaction(); + var stopwatch = new Stopwatch(); + var taskCompletionSource = new TaskCompletionSource(); + _testEnvironment.Alpha.OnMessageReceived = _ => + { + stopwatch.Stop(); + taskCompletionSource.SetResult(stopwatch.Elapsed); + return Task.CompletedTask; + }; + + // Act - send to the dead letters queue instead of normal processing queue + await _testEnvironment.Start(); + var message = transaction.Add(new AlphaCommand { Name = "Test" }); + message.DeliverAt = DateTime.UtcNow.AddMilliseconds(delay); + stopwatch.Start(); + await transaction.Commit(); + var elapsed = await taskCompletionSource.Task; + _testEnvironment.InMemoryMessageBroker.Log.LogInformation("Elapsed time for delayed message: {Elapsed}", + Math.Floor(elapsed.TotalMilliseconds)); + + // Assert + Assert.That(elapsed, Is + .LessThanOrEqualTo(TimeSpan.FromMilliseconds(delay + 1000)) // allow 1 second of leeway + .And.GreaterThanOrEqualTo(TimeSpan.FromMilliseconds(delay - 1000)));// allow 1 second of leeway + } + + [Test, CancelAfter(5 * 60 * 1000)] + public async Task Send_WithExpiredDelay() + { + // Arrange + var transaction = await _testEnvironment.Alpha.Bus.CreateOutgoingTransaction(); + var taskCompletionSource = new TaskCompletionSource(); + _testEnvironment.Alpha.OnMessageReceived = _ => + { + taskCompletionSource.SetResult(true); + return Task.CompletedTask; + }; + + // Act - schedule command to be delivered in the past + await _testEnvironment.Start(); + var message = transaction.Add(new AlphaCommand { Name = "Test" }); + message.DeliverAt = DateTime.UtcNow.AddMilliseconds(-1000); // 1 second in the past + await transaction.Commit(); + await taskCompletionSource.Task; + + // Assert + Assert.That(taskCompletionSource.Task.IsCompleted, Is.True); + } + + [Test, CancelAfter(5 * 60 * 1000)] + public async Task Start_WhenAlreadyStarted() + { + // Act + await _testEnvironment.Start(); + + // Assert - starting again should not throw an error + Assert.DoesNotThrowAsync(_testEnvironment.Start); + } + + [Test, CancelAfter(5 * 60 * 1000)] + public async Task Subscribe_BeforeStarted() + { + // Arrange + var transaction = await _testEnvironment.Alpha.Bus.CreateOutgoingTransaction(); + var taskCompletionSource = new TaskCompletionSource(); + _testEnvironment.Beta.OnMessageReceived = _ => + { + taskCompletionSource.SetResult(true); + return Task.CompletedTask; + }; + + // Act - subscribing before starting should throw an error + Assert.ThrowsAsync(async () => + { + await _testEnvironment.Beta.Transport.Subscribe( + _testEnvironment.Beta.Bus.Messages.GetMessageInfo(typeof(AlphaEvent))!); + }); + transaction.Add(new AlphaEvent { Name = "Test" }); + Assert.ThrowsAsync(transaction.Commit); + + // Assert + Assert.That(taskCompletionSource.Task.IsCompleted, Is.False); + } + + [Test, CancelAfter(5 * 60 * 1000)] + public async Task Subscribe() + { + // Arrange + var transaction = await _testEnvironment.Alpha.Bus.CreateOutgoingTransaction(); + var taskCompletionSource = new TaskCompletionSource(); + _testEnvironment.Beta.OnMessageReceived = _ => + { + taskCompletionSource.SetResult(true); + return Task.CompletedTask; + }; + await _testEnvironment.Start(); + + // Act - subscribing before starting should throw an error + await _testEnvironment.Beta.Transport.Subscribe( + _testEnvironment.Beta.Bus.Messages.GetMessageInfo(typeof(AlphaEvent))!); + transaction.Add(new AlphaEvent { Name = "Test" }); + await transaction.Commit(); + await taskCompletionSource.Task; + } +} diff --git a/src/dotnet/PloyBus.Tests/Transport/InMemory/TestContextLogger.cs b/src/dotnet/PloyBus.Tests/Transport/InMemory/TestContextLogger.cs new file mode 100644 index 0000000..699c63e --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/InMemory/TestContextLogger.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using NUnit.Framework; + +namespace PolyBus.Transport.InMemory; + +[DebuggerStepThrough] +class TestContextLogger(string categoryName) : ILogger +{ + public IDisposable? BeginScope(TState state) + where TState : notnull + => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + TestContext.Out.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff} [{logLevel}] {categoryName}: {formatter(state, exception)}"); + if (exception != null) + { + TestContext.Out.WriteLine(exception); + } + } +} diff --git a/src/dotnet/PloyBus.Tests/Transport/InMemory/TestContextLoggerProvider.cs b/src/dotnet/PloyBus.Tests/Transport/InMemory/TestContextLoggerProvider.cs new file mode 100644 index 0000000..73b6461 --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/InMemory/TestContextLoggerProvider.cs @@ -0,0 +1,14 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace PolyBus.Transport.InMemory; + +[DebuggerStepThrough] +class TestContextLoggerProvider : ILoggerProvider +{ + public ILogger CreateLogger(string categoryName) => new TestContextLogger(categoryName); + + public void Dispose() + { + } +} diff --git a/src/dotnet/PloyBus.Tests/Transport/InMemory/TestEndpoint.cs b/src/dotnet/PloyBus.Tests/Transport/InMemory/TestEndpoint.cs new file mode 100644 index 0000000..7283d64 --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/InMemory/TestEndpoint.cs @@ -0,0 +1,18 @@ +using System.Diagnostics; +using PolyBus.Transport.Transactions; + +namespace PolyBus.Transport.InMemory; + +[DebuggerStepThrough] +class TestEndpoint +{ + public Func OnMessageReceived { get; set; } = _ => Task.CompletedTask; + public IPolyBus Bus { get; set; } = null!; + public PolyBusBuilder Builder { get; } = new(); + public InMemoryEndpoint Transport => (InMemoryEndpoint)Bus.Transport; + public async Task Handler(IncomingTransaction transaction, Func next) + { + await OnMessageReceived(transaction); + await next(); + } +} diff --git a/src/dotnet/PloyBus.Tests/Transport/InMemory/TestEnvironment.cs b/src/dotnet/PloyBus.Tests/Transport/InMemory/TestEnvironment.cs new file mode 100644 index 0000000..d4c754a --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/InMemory/TestEnvironment.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Logging; +using PolyBus.Transport.Transactions.Messages.Handlers.Serializers; + +namespace PolyBus.Transport.InMemory; + +class TestEnvironment +{ + public ILoggerFactory LoggerFactory { get; } = Microsoft.Extensions.Logging.LoggerFactory.Create(builder => + { + builder.AddProvider(new TestContextLoggerProvider()); + }); + public InMemoryMessageBroker InMemoryMessageBroker { get; } = new(); + public TestEndpoint Alpha { get; } = new(); + public TestEndpoint Beta { get; } = new(); + public async Task Setup() + { + await SetupEndpoint(Alpha, "alpha"); + await SetupEndpoint(Beta, "beta"); + } + + async Task SetupEndpoint(TestEndpoint testEndpoint, string name) + { + var jsonHandlers = new JsonHandlers(); + + // add handlers for incoming messages + testEndpoint.Builder.IncomingPipeline.Add(jsonHandlers.Deserializer); + testEndpoint.Builder.IncomingPipeline.Add(testEndpoint.Handler); + + // add messages + testEndpoint.Builder.Messages.Add(typeof(AlphaCommand)); + testEndpoint.Builder.Messages.Add(typeof(AlphaEvent)); + testEndpoint.Builder.Name = name; + + // add handlers for outgoing messages + testEndpoint.Builder.OutgoingPipeline.Add(jsonHandlers.Serializer); + + // configure InMemory transport + testEndpoint.Builder.TransportFactory = InMemoryMessageBroker.AddEndpoint; + InMemoryMessageBroker.Log = LoggerFactory.CreateLogger(); + + // create the bus instance + testEndpoint.Bus = await testEndpoint.Builder.Build(); + } + + public async Task Start() + { + await Alpha.Bus.Start(); + await Beta.Bus.Start(); + } + + public async Task Stop() + { + await Alpha.Bus.Stop(); + await Beta.Bus.Stop(); + } +} diff --git a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Command.cs b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Command.cs new file mode 100644 index 0000000..c20c5b9 --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Command.cs @@ -0,0 +1,4 @@ +namespace PolyBus.Transport.Transactions.Messages; + +[MessageInfo(MessageType.Command, "polybus", "polybus-command", 1, 0, 0)] +class Command; diff --git a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Event.cs b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Event.cs new file mode 100644 index 0000000..e233499 --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Event.cs @@ -0,0 +1,4 @@ +namespace PolyBus.Transport.Transactions.Messages; + +[MessageInfo(MessageType.Event, "polybus", "polybus-event", 2, 1, 3)] +class Event; diff --git a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/ErrorHandlerTestMessage.cs b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/ErrorHandlerTestMessage.cs new file mode 100644 index 0000000..80bd27a --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/ErrorHandlerTestMessage.cs @@ -0,0 +1,4 @@ +namespace PolyBus.Transport.Transactions.Messages.Handlers.Error; + +[MessageInfo(MessageType.Command, "polybus", "error-handler-test-message", 1, 0, 0)] +class ErrorHandlerTestMessage; diff --git a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/ErrorHandlerTests.cs b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/ErrorHandlerTests.cs index 9811194..f32022d 100644 --- a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/ErrorHandlerTests.cs +++ b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/ErrorHandlerTests.cs @@ -14,7 +14,11 @@ public class ErrorHandlerTests public void SetUp() { _testBus = new TestBus("TestBus"); - _incomingMessage = new IncomingMessage(_testBus, "test message body"); + _testBus.Messages.Add(typeof(ErrorHandlerTestMessage)); + _incomingMessage = new IncomingMessage( + bus: _testBus, + body: "{}", + messageInfo: _testBus.Messages.GetMessageInfo(typeof(ErrorHandlerTestMessage))); _transaction = new IncomingTransaction(_testBus, _incomingMessage); _errorHandler = new TestableErrorHandler(); } @@ -88,7 +92,7 @@ Task Next() var delayedMessage = _transaction.OutgoingMessages[0]; Assert.That(delayedMessage.DeliverAt, Is.EqualTo(expectedRetryTime)); - Assert.That(delayedMessage.Headers[ErrorHandler.RetryCountHeader], Is.EqualTo("1")); + Assert.That(delayedMessage.Headers[_errorHandler.RetryCountHeader], Is.EqualTo("1")); Assert.That(delayedMessage.Endpoint, Is.EqualTo("TestBus")); } @@ -96,7 +100,7 @@ Task Next() public async Task Retrier_WithExistingRetryCount_IncrementsCorrectly() { // Arrange - _incomingMessage.Headers[ErrorHandler.RetryCountHeader] = "2"; + _incomingMessage.Headers[_errorHandler.RetryCountHeader] = "2"; var expectedRetryTime = DateTime.UtcNow.AddMinutes(10); _errorHandler.SetNextRetryTime(expectedRetryTime); @@ -109,7 +113,7 @@ public async Task Retrier_WithExistingRetryCount_IncrementsCorrectly() Assert.That(_transaction.OutgoingMessages.Count, Is.EqualTo(1)); var delayedMessage = _transaction.OutgoingMessages[0]; - Assert.That(delayedMessage.Headers[ErrorHandler.RetryCountHeader], Is.EqualTo("3")); + Assert.That(delayedMessage.Headers[_errorHandler.RetryCountHeader], Is.EqualTo("3")); Assert.That(delayedMessage.DeliverAt, Is.EqualTo(expectedRetryTime)); } @@ -117,7 +121,7 @@ public async Task Retrier_WithExistingRetryCount_IncrementsCorrectly() public async Task Retrier_ExceedsMaxDelayedRetries_SendsToDeadLetter() { // Arrange - _incomingMessage.Headers[ErrorHandler.RetryCountHeader] = + _incomingMessage.Headers[_errorHandler.RetryCountHeader] = _errorHandler.DelayedRetryCount.ToString(); var testException = new Exception("Final error"); @@ -131,33 +135,9 @@ public async Task Retrier_ExceedsMaxDelayedRetries_SendsToDeadLetter() Assert.That(_transaction.OutgoingMessages.Count, Is.EqualTo(1)); var deadLetterMessage = _transaction.OutgoingMessages[0]; - Assert.That(deadLetterMessage.Endpoint, Is.EqualTo("TestBus.Errors")); - Assert.That(deadLetterMessage.Headers[ErrorHandler.ErrorMessageHeader], Is.EqualTo("Final error")); - Assert.That(deadLetterMessage.Headers[ErrorHandler.ErrorStackTraceHeader], Is.Not.Null); - } - - [Test] - public async Task Retrier_WithCustomDeadLetterEndpoint_UsesCustomEndpoint() - { - // Arrange - _errorHandler = new TestableErrorHandler - { - DeadLetterEndpoint = "CustomDeadLetter" - }; - - _incomingMessage.Headers[ErrorHandler.RetryCountHeader] = - _errorHandler.DelayedRetryCount.ToString(); - - Task Next() => throw new Exception("Final error"); - - // Act - await _errorHandler.Retrier(_transaction, Next); - - // Assert - Assert.That(_transaction.OutgoingMessages.Count, Is.EqualTo(1)); - - var deadLetterMessage = _transaction.OutgoingMessages[0]; - Assert.That(deadLetterMessage.Endpoint, Is.EqualTo("CustomDeadLetter")); + Assert.That(deadLetterMessage.Endpoint, Is.EqualTo(TestTransport.DefaultDeadLetterEndpoint)); + Assert.That(deadLetterMessage.Headers[_errorHandler.ErrorMessageHeader], Is.EqualTo("Final error")); + Assert.That(deadLetterMessage.Headers[_errorHandler.ErrorStackTraceHeader], Is.Not.Null); } [Test] @@ -169,7 +149,7 @@ public async Task Retrier_ClearsOutgoingMessagesOnEachRetry() Task Next() { callCount++; - _transaction.AddOutgoingMessage("some message", "some endpoint"); + _transaction.Add(new ErrorHandlerTestMessage()); throw new Exception("Test error"); } @@ -180,7 +160,7 @@ Task Next() Assert.That(callCount, Is.EqualTo(_errorHandler.ImmediateRetryCount)); // Should only have the delayed retry message, not the messages added in next() Assert.That(_transaction.OutgoingMessages.Count, Is.EqualTo(1)); - Assert.That(_transaction.OutgoingMessages[0].Headers.ContainsKey(ErrorHandler.RetryCountHeader), Is.True); + Assert.That(_transaction.OutgoingMessages[0].Headers.ContainsKey(_errorHandler.RetryCountHeader), Is.True); } [Test] @@ -205,7 +185,7 @@ Task Next() // Assert Assert.That(callCount, Is.EqualTo(1)); // Should enforce minimum of 1 Assert.That(_transaction.OutgoingMessages.Count, Is.EqualTo(1)); - Assert.That(_transaction.OutgoingMessages[0].Headers[ErrorHandler.RetryCountHeader], Is.EqualTo("1")); + Assert.That(_transaction.OutgoingMessages[0].Headers[_errorHandler.RetryCountHeader], Is.EqualTo("1")); } [Test] @@ -224,7 +204,7 @@ public async Task Retrier_WithZeroDelayedRetries_StillGetsMinimumOfOne() // Assert // Even with DelayedRetryCount = 0, Math.Max(1, DelayedRetryCount) makes it 1 Assert.That(_transaction.OutgoingMessages.Count, Is.EqualTo(1)); - Assert.That(_transaction.OutgoingMessages[0].Headers[ErrorHandler.RetryCountHeader], Is.EqualTo("1")); + Assert.That(_transaction.OutgoingMessages[0].Headers[_errorHandler.RetryCountHeader], Is.EqualTo("1")); Assert.That(_transaction.OutgoingMessages[0].DeliverAt, Is.EqualTo(expectedRetryTime)); } @@ -232,7 +212,7 @@ public async Task Retrier_WithZeroDelayedRetries_StillGetsMinimumOfOne() public void GetNextRetryTime_DefaultImplementation_UsesDelayCorrectly() { // Arrange - var handler = new ErrorHandler { Delay = 60 }; + var handler = new ErrorHandler { DelayIncrement = 60 }; var beforeTime = DateTime.UtcNow; // Act @@ -282,7 +262,7 @@ Task Next() public async Task Retrier_InvalidRetryCountHeader_TreatsAsZero() { // Arrange - _incomingMessage.Headers[ErrorHandler.RetryCountHeader] = "invalid"; + _incomingMessage.Headers[_errorHandler.RetryCountHeader] = "invalid"; var expectedRetryTime = DateTime.UtcNow.AddMinutes(5); _errorHandler.SetNextRetryTime(expectedRetryTime); @@ -294,14 +274,14 @@ public async Task Retrier_InvalidRetryCountHeader_TreatsAsZero() // Assert Assert.That(_transaction.OutgoingMessages.Count, Is.EqualTo(1)); var delayedMessage = _transaction.OutgoingMessages[0]; - Assert.That(delayedMessage.Headers[ErrorHandler.RetryCountHeader], Is.EqualTo("1")); + Assert.That(delayedMessage.Headers[_errorHandler.RetryCountHeader], Is.EqualTo("1")); } [Test] public async Task Retrier_ExceptionStackTrace_IsStoredInHeader() { // Arrange - _incomingMessage.Headers[ErrorHandler.RetryCountHeader] = + _incomingMessage.Headers[_errorHandler.RetryCountHeader] = _errorHandler.DelayedRetryCount.ToString(); var exceptionWithStackTrace = new Exception("Error with stack trace"); @@ -314,15 +294,15 @@ public async Task Retrier_ExceptionStackTrace_IsStoredInHeader() // Assert Assert.That(_transaction.OutgoingMessages.Count, Is.EqualTo(1)); var deadLetterMessage = _transaction.OutgoingMessages[0]; - Assert.That(deadLetterMessage.Headers[ErrorHandler.ErrorStackTraceHeader], Is.Not.Null); - Assert.That(deadLetterMessage.Headers[ErrorHandler.ErrorStackTraceHeader], Is.Not.Empty); + Assert.That(deadLetterMessage.Headers[_errorHandler.ErrorStackTraceHeader], Is.Not.Null); + Assert.That(deadLetterMessage.Headers[_errorHandler.ErrorStackTraceHeader], Is.Not.Empty); } [Test] public async Task Retrier_ExceptionWithNullStackTrace_UsesEmptyString() { // Arrange - _incomingMessage.Headers[ErrorHandler.RetryCountHeader] = + _incomingMessage.Headers[_errorHandler.RetryCountHeader] = _errorHandler.DelayedRetryCount.ToString(); // Create an exception with null StackTrace using custom exception @@ -336,7 +316,7 @@ public async Task Retrier_ExceptionWithNullStackTrace_UsesEmptyString() // Assert Assert.That(_transaction.OutgoingMessages.Count, Is.EqualTo(1)); var deadLetterMessage = _transaction.OutgoingMessages[0]; - Assert.That(deadLetterMessage.Headers[ErrorHandler.ErrorStackTraceHeader], Is.EqualTo(string.Empty)); + Assert.That(deadLetterMessage.Headers[_errorHandler.ErrorStackTraceHeader], Is.EqualTo(string.Empty)); } // Helper class to override GetNextRetryTime for testing @@ -354,46 +334,4 @@ public override DateTime GetNextRetryTime(int attempt) return _nextRetryTime ?? base.GetNextRetryTime(attempt); } } - - // Custom exception that returns null for StackTrace - private class ExceptionWithNullStackTrace(string message) : Exception(message) - { - public override string? StackTrace => null; - } - - // Test implementation of IPolyBus for testing purposes - private class TestBus(string name) : IPolyBus - { - public IDictionary Properties { get; } = new Dictionary(); - public ITransport Transport { get; } = new TestTransport(); - public IList IncomingHandlers { get; } = []; - public IList OutgoingHandlers { get; } = []; - public Messages Messages { get; } = new(); - public string Name { get; } = name; - - public Task CreateTransaction(IncomingMessage? message = null) - { - Transaction transaction = message == null - ? new OutgoingTransaction(this) - : new IncomingTransaction(this, message); - return Task.FromResult(transaction); - } - - public Task Send(Transaction transaction) => Task.CompletedTask; - public Task Start() => Task.CompletedTask; - public Task Stop() => Task.CompletedTask; - } - - // Simple test transport implementation - private class TestTransport : ITransport - { - public bool SupportsCommandMessages => true; - public bool SupportsDelayedMessages => true; - public bool SupportsSubscriptions => false; - - public Task Send(Transaction transaction) => Task.CompletedTask; - public Task Subscribe(MessageInfo messageInfo) => Task.CompletedTask; - public Task Start() => Task.CompletedTask; - public Task Stop() => Task.CompletedTask; - } } diff --git a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/ExceptionWithNullStackTrace.cs b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/ExceptionWithNullStackTrace.cs new file mode 100644 index 0000000..1293755 --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/ExceptionWithNullStackTrace.cs @@ -0,0 +1,7 @@ +namespace PolyBus.Transport.Transactions.Messages.Handlers.Error; + +// Custom exception that returns null for StackTrace +class ExceptionWithNullStackTrace(string message) : Exception(message) +{ + public override string? StackTrace => null; +} diff --git a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/TestBus.cs b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/TestBus.cs new file mode 100644 index 0000000..7680f4d --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/TestBus.cs @@ -0,0 +1,18 @@ +namespace PolyBus.Transport.Transactions.Messages.Handlers.Error; + +class TestBus(string name) : IPolyBus +{ + public IDictionary Properties { get; } = new Dictionary(); + public ITransport Transport { get; } = new TestTransport(); + public IList IncomingPipeline { get; } = []; + public IList OutgoingPipeline { get; } = []; + public Messages Messages { get; } = new(); + public Task CreateIncomingTransaction(IncomingMessage message) => + Task.FromResult(new IncomingTransaction(this, message)); + public Task CreateOutgoingTransaction() => + Task.FromResult(new OutgoingTransaction(this)); + public string Name { get; } = name; + public Task Send(Transaction transaction) => Task.CompletedTask; + public Task Start() => Task.CompletedTask; + public Task Stop() => Task.CompletedTask; +} diff --git a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/TestTransport.cs b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/TestTransport.cs new file mode 100644 index 0000000..a2f705d --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Error/TestTransport.cs @@ -0,0 +1,16 @@ +namespace PolyBus.Transport.Transactions.Messages.Handlers.Error; + +class TestTransport : ITransport +{ + public const string DefaultDeadLetterEndpoint = "dead-letters"; + public string DeadLetterEndpoint => DefaultDeadLetterEndpoint; + public Task Handle(Transaction transaction) => throw new NotImplementedException(); + public bool SupportsCommandMessages => true; + public bool SupportsDelayedCommands => true; + public bool SupportsSubscriptions => false; + + public Task Send(Transaction transaction) => Task.CompletedTask; + public Task Subscribe(MessageInfo messageInfo) => Task.CompletedTask; + public Task Start() => Task.CompletedTask; + public Task Stop() => Task.CompletedTask; +} diff --git a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Serializers/JsonHandlerTestMessage.cs b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Serializers/JsonHandlerTestMessage.cs new file mode 100644 index 0000000..83a8a0e --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Serializers/JsonHandlerTestMessage.cs @@ -0,0 +1,7 @@ +namespace PolyBus.Transport.Transactions.Messages.Handlers.Serializers; + +[MessageInfo(MessageType.Command, "polybus", "json-handler-test-message", 1, 0, 0)] +class JsonHandlerTestMessage +{ + public required string Text { get; init; } +} diff --git a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Serializers/JsonHandlersTests.cs b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Serializers/JsonHandlersTests.cs index 751039c..c258ce6 100644 --- a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Serializers/JsonHandlersTests.cs +++ b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/Handlers/Serializers/JsonHandlersTests.cs @@ -1,5 +1,3 @@ -using System.Text.Json; -using System.Text.Json.Nodes; using NUnit.Framework; namespace PolyBus.Transport.Transactions.Messages.Handlers.Serializers; @@ -8,418 +6,33 @@ namespace PolyBus.Transport.Transactions.Messages.Handlers.Serializers; public class JsonHandlersTests { private JsonHandlers _jsonHandlers = null!; - private IPolyBus _mockBus = null!; private Messages _messages = null!; [SetUp] public void SetUp() { _jsonHandlers = new JsonHandlers(); - _messages = new Messages(); - _mockBus = new MockPolyBus(_messages); + _messages = new(); + _messages.Add(typeof(JsonHandlerTestMessage)); } - #region Deserializer Tests - - [Test] - public async Task Deserializer_WithValidTypeHeader_DeserializesMessage() - { - // Arrange - var testMessage = new TestMessage { Id = 1, Name = "Test" }; - var serializedBody = JsonSerializer.Serialize(testMessage); - - _messages.Add(typeof(TestMessage)); - - var incomingMessage = new IncomingMessage(_mockBus, serializedBody) - { - Headers = new Dictionary - { - [Headers.MessageType] = _header - } - }; - var transaction = new MockIncomingTransaction(_mockBus, incomingMessage); - - var nextCalled = false; - Task Next() { nextCalled = true; return Task.CompletedTask; } - - // Act - await _jsonHandlers.Deserializer(transaction, Next); - - // Assert - Assert.That(nextCalled, Is.True); - Assert.That(incomingMessage.Message, Is.Not.Null); - Assert.That(incomingMessage.Message, Is.TypeOf()); - var deserializedMessage = (TestMessage)incomingMessage.Message; - Assert.That(deserializedMessage.Id, Is.EqualTo(1)); - Assert.That(deserializedMessage.Name, Is.EqualTo("Test")); - } - - [Test] - public async Task Deserializer_WithValidTypeHeaderAndCustomOptions_DeserializesWithOptions() - { - // Arrange - var options = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - var jsonHandlers = new JsonHandlers { JsonSerializerOptions = options }; - - var testMessage = new TestMessage { Id = 2, Name = "CamelCase" }; - var serializedBody = JsonSerializer.Serialize(testMessage, options); - - _messages.Add(typeof(TestMessage)); - - var incomingMessage = new IncomingMessage(_mockBus, serializedBody) - { - Headers = new Dictionary - { - [Headers.MessageType] = _header - } - }; - var transaction = new MockIncomingTransaction(_mockBus, incomingMessage); - - var nextCalled = false; - Task Next() { nextCalled = true; return Task.CompletedTask; } - - // Act - await jsonHandlers.Deserializer(transaction, Next); - - // Assert - Assert.That(nextCalled, Is.True); - Assert.That(incomingMessage.Message, Is.TypeOf()); - var deserializedMessage = (TestMessage)incomingMessage.Message; - Assert.That(deserializedMessage.Id, Is.EqualTo(2)); - Assert.That(deserializedMessage.Name, Is.EqualTo("CamelCase")); - } - - [Test] - public async Task Deserializer_WithUnknownTypeAndThrowOnMissingTypeFalse_ParsesAsJsonNode() - { - // Arrange - var jsonHandlers = new JsonHandlers { ThrowOnMissingType = false }; - var testObject = new { Id = 3, Name = "Unknown" }; - var serializedBody = JsonSerializer.Serialize(testObject); - var header = "endpoint=test-service, type=Command, name=UnknownMessage, version=1.0.0"; - - var incomingMessage = new IncomingMessage(_mockBus, serializedBody) - { - Headers = new Dictionary - { - [Headers.MessageType] = header - } - }; - var transaction = new MockIncomingTransaction(_mockBus, incomingMessage); - - var nextCalled = false; - Task Next() { nextCalled = true; return Task.CompletedTask; } - - // Act - await jsonHandlers.Deserializer(transaction, Next); - - // Assert - Assert.That(nextCalled, Is.True); - Assert.That(incomingMessage.Message, Is.Not.Null); - Assert.That(incomingMessage.Message, Is.InstanceOf()); - var jsonNode = (JsonNode)incomingMessage.Message; - Assert.That(jsonNode["Id"]?.GetValue(), Is.EqualTo(3)); - Assert.That(jsonNode["Name"]?.GetValue(), Is.EqualTo("Unknown")); - } - - [Test] - public void Deserializer_WithUnknownTypeAndThrowOnMissingTypeTrue_ThrowsException() - { - // Arrange - var jsonHandlers = new JsonHandlers { ThrowOnMissingType = true }; - var testObject = new { Id = 4, Name = "Error" }; - var serializedBody = JsonSerializer.Serialize(testObject); - var header = "endpoint=test-service, type=Command, name=UnknownMessage, version=1.0.0"; - - var incomingMessage = new IncomingMessage(_mockBus, serializedBody) - { - Headers = new Dictionary - { - [Headers.MessageType] = header - } - }; - var transaction = new MockIncomingTransaction(_mockBus, incomingMessage); - - Task Next() => Task.CompletedTask; - - // Act & Assert - var ex = Assert.ThrowsAsync( - () => jsonHandlers.Deserializer(transaction, Next)); - Assert.That(ex!.Message, Is.EqualTo("The type header is missing, invalid, or if the type cannot be found.")); - } - - [Test] - public void Deserializer_WithMissingTypeHeader_SkipsDeserialization() - { - // Arrange - var jsonHandlers = new JsonHandlers { ThrowOnMissingType = true }; - var incomingMessage = new IncomingMessage(_mockBus, "{}") - { - Headers = new Dictionary() - }; - var transaction = new MockIncomingTransaction(_mockBus, incomingMessage); - - Task Next() => Task.CompletedTask; - - // Act & Assert - var ex = Assert.ThrowsAsync( - () => jsonHandlers.Deserializer(transaction, Next)); - Assert.That(ex!.Message, Is.EqualTo("The type header is missing, invalid, or if the type cannot be found.")); - } - - [Test] - public void Deserializer_WithInvalidJson_ThrowsJsonException() - { - // Arrange - _messages.Add(typeof(TestMessage)); - - var incomingMessage = new IncomingMessage(_mockBus, "invalid json") - { - Headers = new Dictionary - { - [Headers.MessageType] = _header - } - }; - var transaction = new MockIncomingTransaction(_mockBus, incomingMessage); - - Task Next() => Task.CompletedTask; - - // Act & Assert - Assert.ThrowsAsync(() => _jsonHandlers.Deserializer(transaction, Next)); - } - - #endregion - - #region Serializer Tests - - [Test] - public async Task Serializer_WithValidMessage_SerializesAndSetsHeaders() - { - // Arrange - var testMessage = new TestMessage { Id = 5, Name = "Serialize" }; - var messageType = typeof(TestMessage); - var expectedHeader = _header; - - _messages.Add(messageType); - - var mockTransaction = new MockOutgoingTransaction(_mockBus); - var outgoingMessage = mockTransaction.AddOutgoingMessage(testMessage); - - var nextCalled = false; - Task Next() { nextCalled = true; return Task.CompletedTask; } - - // Act - await _jsonHandlers.Serializer(mockTransaction, Next); - - // Assert - Assert.That(nextCalled, Is.True); - Assert.That(outgoingMessage.Body, Is.Not.Null); - - var deserializedMessage = JsonSerializer.Deserialize(outgoingMessage.Body); - Assert.That(deserializedMessage!.Id, Is.EqualTo(5)); - Assert.That(deserializedMessage.Name, Is.EqualTo("Serialize")); - - Assert.That(outgoingMessage.Headers[Headers.ContentType], Is.EqualTo("application/json")); - Assert.That(outgoingMessage.Headers[Headers.MessageType], Is.EqualTo(expectedHeader)); - } - - [Test] - public async Task Serializer_WithCustomContentType_UsesCustomContentType() - { - // Arrange - var customContentType = "application/custom-json"; - var jsonHandlers = new JsonHandlers { ContentType = customContentType, ThrowOnInvalidType = false }; - - var testMessage = new TestMessage { Id = 6, Name = "Custom" }; - _messages.Add(typeof(TestMessage)); - - var mockTransaction = new MockOutgoingTransaction(_mockBus); - var outgoingMessage = mockTransaction.AddOutgoingMessage(testMessage); - - var nextCalled = false; - Task Next() { nextCalled = true; return Task.CompletedTask; } - - // Act - await jsonHandlers.Serializer(mockTransaction, Next); - - // Assert - Assert.That(nextCalled, Is.True); - Assert.That(outgoingMessage.Headers[Headers.ContentType], Is.EqualTo(customContentType)); - } - - [Test] - public async Task Serializer_WithCustomJsonOptions_SerializesWithOptions() + public async Task Serializer_SetsBodyAndContentType() { // Arrange - var options = new JsonSerializerOptions + var message = new JsonHandlerTestMessage { Text = "Hello, World!" }; + var outgoingMessage = new OutgoingMessage(null!, message) { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase + Message = message, + MessageType = typeof(JsonHandlerTestMessage) }; - var jsonHandlers = new JsonHandlers { JsonSerializerOptions = options, ThrowOnInvalidType = false }; - - var testMessage = new TestMessage { Id = 7, Name = "Options" }; - _messages.Add(typeof(TestMessage)); - - var mockTransaction = new MockOutgoingTransaction(_mockBus); - var outgoingMessage = mockTransaction.AddOutgoingMessage(testMessage); - - var nextCalled = false; - Task Next() { nextCalled = true; return Task.CompletedTask; } - - // Act - await jsonHandlers.Serializer(mockTransaction, Next); - - // Assert - Assert.That(nextCalled, Is.True); - Assert.That(outgoingMessage.Body, Contains.Substring("\"id\":")); - Assert.That(outgoingMessage.Body, Contains.Substring("\"name\":")); - } - - [Test] - public async Task Serializer_WithUnknownTypeAndThrowOnInvalidTypeFalse_SkipsHeaderSetting() - { - // Arrange - var jsonHandlers = new JsonHandlers { ThrowOnInvalidType = false }; - - var testMessage = new UnknownMessage { Data = "test" }; - - var mockTransaction = new MockOutgoingTransaction(_mockBus); - var outgoingMessage = mockTransaction.AddOutgoingMessage(testMessage, "unknown-endpoint"); - - var nextCalled = false; - Task Next() { nextCalled = true; return Task.CompletedTask; } + var transaction = new OutgoingTransaction(null!); + transaction.Add(message); // Act - await jsonHandlers.Serializer(mockTransaction, Next); + await _jsonHandlers.Serializer(transaction, () => Task.CompletedTask); // Assert - Assert.That(nextCalled, Is.True); Assert.That(outgoingMessage.Body, Is.Not.Null); Assert.That(outgoingMessage.Headers[Headers.ContentType], Is.EqualTo("application/json")); - Assert.That(outgoingMessage.Headers.ContainsKey(Headers.MessageType), Is.False); - } - - [Test] - public void Serializer_WithUnknownTypeAndThrowOnInvalidTypeTrue_ThrowsException() - { - // Arrange - var jsonHandlers = new JsonHandlers { ThrowOnInvalidType = true }; - - var testMessage = new UnknownMessage { Data = "error" }; - - var mockTransaction = new MockOutgoingTransaction(_mockBus); - mockTransaction.AddOutgoingMessage(testMessage, "unknown-endpoint"); - - Task Next() => Task.CompletedTask; - - // Act & Assert - var ex = Assert.ThrowsAsync( - () => jsonHandlers.Serializer(mockTransaction, Next)); - Assert.That(ex!.Message, Is.EqualTo("The header has an valid type.")); - } - - [Test] - public async Task Serializer_WithMultipleMessages_SerializesAll() - { - // Arrange - var testMessage1 = new TestMessage { Id = 8, Name = "First" }; - var testMessage2 = new TestMessage { Id = 9, Name = "Second" }; - - _messages.Add(typeof(TestMessage)); - - var mockTransaction = new MockOutgoingTransaction(_mockBus); - var outgoingMessage1 = mockTransaction.AddOutgoingMessage(testMessage1); - outgoingMessage1.Headers[Headers.MessageType] = _header; - var outgoingMessage2 = mockTransaction.AddOutgoingMessage(testMessage2); - outgoingMessage2.Headers[Headers.MessageType] = _header; - - var nextCalled = false; - Task Next() { nextCalled = true; return Task.CompletedTask; } - - // Act - await _jsonHandlers.Serializer(mockTransaction, Next); - - // Assert - Assert.That(nextCalled, Is.True); - Assert.That(outgoingMessage1.Body, Is.Not.Null); - Assert.That(outgoingMessage2.Body, Is.Not.Null); - - var deserializedMessage1 = JsonSerializer.Deserialize(outgoingMessage1.Body); - var deserializedMessage2 = JsonSerializer.Deserialize(outgoingMessage2.Body); - - Assert.That(deserializedMessage1!.Id, Is.EqualTo(8)); - Assert.That(deserializedMessage1.Name, Is.EqualTo("First")); - Assert.That(deserializedMessage2!.Id, Is.EqualTo(9)); - Assert.That(deserializedMessage2.Name, Is.EqualTo("Second")); - } - - [Test] - public async Task Serializer_WithEmptyOutgoingMessages_CallsNext() - { - // Arrange - var mockTransaction = new MockOutgoingTransaction(_mockBus); - - var nextCalled = false; - Task Next() { nextCalled = true; return Task.CompletedTask; } - - // Act - await _jsonHandlers.Serializer(mockTransaction, Next); - - // Assert - Assert.That(nextCalled, Is.True); - } - - #endregion - - #region Test Support Classes - - const string _header = "endpoint=test-service, type=Command, name=TestMessage, version=1.0.0"; - [MessageInfo(MessageType.Command, "test-service", "TestMessage", 1, 0, 0)] - public class TestMessage - { - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - } - - public class UnknownMessage - { - public string Data { get; set; } = string.Empty; - } - - #endregion - - private class MockPolyBus(Messages messages) : IPolyBus - { - public IDictionary Properties => null!; - public ITransport Transport => null!; - public IList IncomingHandlers => []; - public IList OutgoingHandlers => []; - public Messages Messages { get; } = messages; - public string Name => "MockBus"; - - public Task CreateTransaction(IncomingMessage? message = null) => - Task.FromResult( - message != null - ? new MockIncomingTransaction(this, message) - : new MockOutgoingTransaction(this)); - - public Task Send(Transaction transaction) => Task.CompletedTask; - public Task Start() => Task.CompletedTask; - public Task Stop() => Task.CompletedTask; - } - - private class MockOutgoingTransaction(IPolyBus bus) : OutgoingTransaction(bus) - { - public override Task Abort() => Task.CompletedTask; - public override Task Commit() => Task.CompletedTask; - } - - private class MockIncomingTransaction(IPolyBus bus, IncomingMessage incomingMessage) : IncomingTransaction(bus, incomingMessage) - { - public override Task Abort() => Task.CompletedTask; - public override Task Commit() => Task.CompletedTask; } } diff --git a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/MessageInfoTests.cs b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/MessageInfoTests.cs index fee88d7..1ac67cf 100644 --- a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/MessageInfoTests.cs +++ b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/MessageInfoTests.cs @@ -11,7 +11,7 @@ public class MessageInfoTests public void GetAttributeFromHeader_WithValidHeader_ReturnsCorrectAttribute() { // Arrange - var header = "endpoint=user-service, type=Command, name=CreateUser, version=1.2.3"; + var header = "endpoint=user-service, type=Command, name=create-user, version=1.2.3"; // Act var result = MessageInfo.GetAttributeFromHeader(header); @@ -20,7 +20,7 @@ public void GetAttributeFromHeader_WithValidHeader_ReturnsCorrectAttribute() Assert.That(result, Is.Not.Null); Assert.That(result.Endpoint, Is.EqualTo("user-service")); Assert.That(result.Type, Is.EqualTo(MessageType.Command)); - Assert.That(result.Name, Is.EqualTo("CreateUser")); + Assert.That(result.Name, Is.EqualTo("create-user")); Assert.That(result.Major, Is.EqualTo(1)); Assert.That(result.Minor, Is.EqualTo(2)); Assert.That(result.Patch, Is.EqualTo(3)); @@ -30,7 +30,7 @@ public void GetAttributeFromHeader_WithValidHeader_ReturnsCorrectAttribute() public void GetAttributeFromHeader_WithEventType_ReturnsCorrectAttribute() { // Arrange - var header = "endpoint=notification-service, type=Event, name=UserCreated, version=2.0.1"; + var header = "endpoint=notification-service, type=Event, name=user-created, version=2.0.1"; // Act var result = MessageInfo.GetAttributeFromHeader(header); @@ -39,7 +39,7 @@ public void GetAttributeFromHeader_WithEventType_ReturnsCorrectAttribute() Assert.That(result, Is.Not.Null); Assert.That(result!.Endpoint, Is.EqualTo("notification-service")); Assert.That(result.Type, Is.EqualTo(MessageType.Event)); - Assert.That(result.Name, Is.EqualTo("UserCreated")); + Assert.That(result.Name, Is.EqualTo("user-created")); Assert.That(result.Major, Is.EqualTo(2)); Assert.That(result.Minor, Is.EqualTo(0)); Assert.That(result.Patch, Is.EqualTo(1)); @@ -49,7 +49,7 @@ public void GetAttributeFromHeader_WithEventType_ReturnsCorrectAttribute() public void GetAttributeFromHeader_WithExtraSpaces_ReturnsCorrectAttribute() { // Arrange - the current regex doesn't handle spaces within values well, so testing valid spacing - var header = "endpoint=payment-service, type=Command, name=ProcessPayment, version=3.14.159"; + var header = "endpoint=payment-service, type=Command, name=process-payment, version=3.14.159"; // Act var result = MessageInfo.GetAttributeFromHeader(header); @@ -58,7 +58,7 @@ public void GetAttributeFromHeader_WithExtraSpaces_ReturnsCorrectAttribute() Assert.That(result, Is.Not.Null); Assert.That(result!.Endpoint, Is.EqualTo("payment-service")); Assert.That(result.Type, Is.EqualTo(MessageType.Command)); - Assert.That(result.Name, Is.EqualTo("ProcessPayment")); + Assert.That(result.Name, Is.EqualTo("process-payment")); Assert.That(result.Major, Is.EqualTo(3)); Assert.That(result.Minor, Is.EqualTo(14)); Assert.That(result.Patch, Is.EqualTo(159)); @@ -68,7 +68,7 @@ public void GetAttributeFromHeader_WithExtraSpaces_ReturnsCorrectAttribute() public void GetAttributeFromHeader_WithCaseInsensitiveType_ReturnsCorrectAttribute() { // Arrange - var header = "endpoint=order-service, type=command, name=PlaceOrder, version=1.0.0"; + var header = "endpoint=order-service, type=command, name=place-order, version=1.0.0"; // Act var result = MessageInfo.GetAttributeFromHeader(header); @@ -82,8 +82,8 @@ public void GetAttributeFromHeader_WithCaseInsensitiveType_ReturnsCorrectAttribu [TestCase("invalid header")] [TestCase("endpoint=test")] [TestCase("endpoint=test, type=Command")] - [TestCase("endpoint=test, type=Command, name=Test")] - [TestCase("endpoint=test, type=Command, name=Test, version=invalid")] + [TestCase("endpoint=test, type=Command, name=test")] + [TestCase("endpoint=test, type=Command, name=test, version=invalid")] [TestCase("type=Command, name=Test, version=1.0.0")] public void GetAttributeFromHeader_WithInvalidHeader_ReturnsNull(string header) { @@ -98,7 +98,7 @@ public void GetAttributeFromHeader_WithInvalidHeader_ReturnsNull(string header) public void GetAttributeFromHeader_WithInvalidEnumType_ThrowsArgumentException() { // Arrange - var header = "endpoint=test, type=InvalidType, name=Test, version=1.0.0"; + var header = "endpoint=test, type=InvalidType, name=test, version=1.0.0"; // Act & Assert Assert.Throws(() => MessageInfo.GetAttributeFromHeader(header)); @@ -108,7 +108,7 @@ public void GetAttributeFromHeader_WithInvalidEnumType_ThrowsArgumentException() public void GetAttributeFromHeader_WithMissingVersion_ReturnsNull() { // Arrange - var header = "endpoint=test-service, type=Command, name=TestCommand, version="; + var header = "endpoint=test-service, type=Command, name=test-command, version="; // Act var result = MessageInfo.GetAttributeFromHeader(header); @@ -121,7 +121,7 @@ public void GetAttributeFromHeader_WithMissingVersion_ReturnsNull() public void GetAttributeFromHeader_WithIncompleteVersion_ReturnsNull() { // Arrange - var header = "endpoint=test-service, type=Command, name=TestCommand, version=1.0"; + var header = "endpoint=test-service, type=Command, name=test-command, version=1.0"; // Act var result = MessageInfo.GetAttributeFromHeader(header); @@ -134,8 +134,8 @@ public void GetAttributeFromHeader_WithIncompleteVersion_ReturnsNull() public void Equals_WithIdenticalAttributes_ReturnsTrue() { // Arrange - var attr1 = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 3); - var attr2 = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 3); + var attr1 = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 3); + var attr2 = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 3); // Act & Assert Assert.That(attr1.Equals(attr2), Is.True); @@ -146,7 +146,7 @@ public void Equals_WithIdenticalAttributes_ReturnsTrue() public void Equals_WithSameObject_ReturnsTrue() { // Arrange - var attr = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 3); + var attr = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 3); // Act & Assert Assert.That(attr.Equals(attr), Is.True); @@ -156,8 +156,8 @@ public void Equals_WithSameObject_ReturnsTrue() public void Equals_WithDifferentType_ReturnsFalse() { // Arrange - var attr1 = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 3); - var attr2 = new MessageInfo(MessageType.Event, "user-service", "CreateUser", 1, 2, 3); + var attr1 = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 3); + var attr2 = new MessageInfo(MessageType.Event, "user-service", "create-user", 1, 2, 3); // Act & Assert Assert.That(attr1.Equals(attr2), Is.False); @@ -167,8 +167,8 @@ public void Equals_WithDifferentType_ReturnsFalse() public void Equals_WithDifferentEndpoint_ReturnsFalse() { // Arrange - var attr1 = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 3); - var attr2 = new MessageInfo(MessageType.Command, "order-service", "CreateUser", 1, 2, 3); + var attr1 = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 3); + var attr2 = new MessageInfo(MessageType.Command, "order-service", "create-user", 1, 2, 3); // Act & Assert Assert.That(attr1.Equals(attr2), Is.False); @@ -178,8 +178,8 @@ public void Equals_WithDifferentEndpoint_ReturnsFalse() public void Equals_WithDifferentName_ReturnsFalse() { // Arrange - var attr1 = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 3); - var attr2 = new MessageInfo(MessageType.Command, "user-service", "UpdateUser", 1, 2, 3); + var attr1 = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 3); + var attr2 = new MessageInfo(MessageType.Command, "user-service", "update-user", 1, 2, 3); // Act & Assert Assert.That(attr1.Equals(attr2), Is.False); @@ -189,8 +189,8 @@ public void Equals_WithDifferentName_ReturnsFalse() public void Equals_WithDifferentMajorVersion_ReturnsFalse() { // Arrange - var attr1 = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 3); - var attr2 = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 2, 2, 3); + var attr1 = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 3); + var attr2 = new MessageInfo(MessageType.Command, "user-service", "create-user", 2, 2, 3); // Act & Assert Assert.That(attr1.Equals(attr2), Is.False); @@ -200,8 +200,8 @@ public void Equals_WithDifferentMajorVersion_ReturnsFalse() public void Equals_WithDifferentMinorVersion_ReturnsTrue() { // Arrange - var attr1 = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 3); - var attr2 = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 3, 3); + var attr1 = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 3); + var attr2 = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 3, 3); // Act & Assert Assert.That(attr1.Equals(attr2), Is.True, "Minor version differences should not affect equality"); @@ -211,8 +211,8 @@ public void Equals_WithDifferentMinorVersion_ReturnsTrue() public void Equals_WithDifferentPatchVersion_ReturnsTrue() { // Arrange - var attr1 = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 3); - var attr2 = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 4); + var attr1 = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 3); + var attr2 = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 4); // Act & Assert Assert.That(attr1.Equals(attr2), Is.True, "Patch version differences should not affect equality"); @@ -222,7 +222,7 @@ public void Equals_WithDifferentPatchVersion_ReturnsTrue() public void Equals_WithNullObject_ReturnsFalse() { // Arrange - var attr = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 3); + var attr = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 3); // Act & Assert Assert.That(attr.Equals(null), Is.False); @@ -232,7 +232,7 @@ public void Equals_WithNullObject_ReturnsFalse() public void Equals_WithDifferentObjectType_ReturnsFalse() { // Arrange - var attr = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 3); + var attr = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 3); var other = "not a MessageAttribute"; // Act & Assert @@ -243,8 +243,8 @@ public void Equals_WithDifferentObjectType_ReturnsFalse() public void GetHashCode_WithIdenticalAttributes_ReturnsSameHashCode() { // Arrange - var attr1 = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 3); - var attr2 = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 3); + var attr1 = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 3); + var attr2 = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 3); // Act & Assert Assert.That(attr1.GetHashCode(), Is.EqualTo(attr2.GetHashCode())); @@ -254,8 +254,8 @@ public void GetHashCode_WithIdenticalAttributes_ReturnsSameHashCode() public void GetHashCode_WithDifferentAttributes_ReturnsDifferentHashCodes() { // Arrange - var attr1 = new MessageInfo(MessageType.Command, "user-service", "CreateUser", 1, 2, 3); - var attr2 = new MessageInfo(MessageType.Event, "user-service", "CreateUser", 1, 2, 3); + var attr1 = new MessageInfo(MessageType.Command, "user-service", "create-user", 1, 2, 3); + var attr2 = new MessageInfo(MessageType.Event, "user-service", "create-user", 1, 2, 3); // Act & Assert Assert.That(attr1.GetHashCode(), Is.Not.EqualTo(attr2.GetHashCode())); diff --git a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/MessageWithoutAttribute.cs b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/MessageWithoutAttribute.cs new file mode 100644 index 0000000..9b65a98 --- /dev/null +++ b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/MessageWithoutAttribute.cs @@ -0,0 +1,3 @@ +namespace PolyBus.Transport.Transactions.Messages; + +class MessageWithoutAttribute; diff --git a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/MessagesTests.cs b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/MessagesTests.cs index 738b852..523fdea 100644 --- a/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/MessagesTests.cs +++ b/src/dotnet/PloyBus.Tests/Transport/Transactions/Messages/MessagesTests.cs @@ -13,261 +13,148 @@ public void SetUp() _messages = new Messages(); } - #region Test Message Classes - - [MessageInfo(MessageType.Command, "OrderService", "CreateOrder", 1, 0, 0)] - public class CreateOrderCommand - { - public string OrderId { get; set; } = string.Empty; - public decimal Amount { get; set; } - } - - [MessageInfo(MessageType.Event, "OrderService", "OrderCreated", 2, 1, 3)] - public class OrderCreatedEvent - { - public string OrderId { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } - } - - [MessageInfo(MessageType.Command, "PaymentService", "ProcessPayment", 1, 5, 2)] - public class ProcessPaymentCommand - { - public string PaymentId { get; set; } = string.Empty; - public decimal Amount { get; set; } - } - - public class MessageWithoutAttribute - { - public string Data { get; set; } = string.Empty; - } - - #endregion - [Test] public void Add_ValidMessageType_ReturnsMessageInfo() { // Act - var result = _messages.Add(typeof(CreateOrderCommand)); + var result = _messages.Add(typeof(Command)); // Assert Assert.That(result, Is.Not.Null); Assert.That(result.Type, Is.EqualTo(MessageType.Command)); - Assert.That(result.Endpoint, Is.EqualTo("OrderService")); - Assert.That(result.Name, Is.EqualTo("CreateOrder")); + Assert.That(result.Endpoint, Is.EqualTo("polybus")); + Assert.That(result.Name, Is.EqualTo("polybus-command")); Assert.That(result.Major, Is.EqualTo(1)); Assert.That(result.Minor, Is.EqualTo(0)); Assert.That(result.Patch, Is.EqualTo(0)); } [Test] - public void Add_MessageTypeWithoutAttribute_ThrowsArgumentException() + public void Add_MessageTypeWithoutAttribute_ThrowsError() { // Act & Assert - var exception = Assert.Throws(() => _messages.Add(typeof(MessageWithoutAttribute))); - Assert.That(exception.Message, Does.Contain("does not have a MessageAttribute")); - Assert.That(exception.Message, Does.Contain(typeof(MessageWithoutAttribute).FullName)); + Assert.Throws(() => _messages.Add(typeof(MessageWithoutAttribute))); } [Test] public void GetMessageInfo_ExistingType_ReturnsCorrectMessageInfo() { // Arrange - _messages.Add(typeof(CreateOrderCommand)); + _messages.Add(typeof(Command)); // Act - var result = _messages.GetMessageInfo(typeof(CreateOrderCommand)); + var result = _messages.GetMessageInfo(typeof(Command)); // Assert Assert.That(result, Is.Not.Null); Assert.That(result.Type, Is.EqualTo(MessageType.Command)); - Assert.That(result.Endpoint, Is.EqualTo("OrderService")); - Assert.That(result.Name, Is.EqualTo("CreateOrder")); - } - - [Test] - public void GetMessageInfo_NonExistentType_ReturnsNull() - { - // Act - var result = _messages.GetMessageInfo(typeof(CreateOrderCommand)); - - // Assert - Assert.That(result, Is.Null); + Assert.That(result.Endpoint, Is.EqualTo("polybus")); + Assert.That(result.Name, Is.EqualTo("polybus-command")); } [Test] - public void GetHeader_ExistingType_ReturnsCorrectHeader() + public void GetMessageInfo_NonExistentType_ThrowsError() { - // Arrange - _messages.Add(typeof(OrderCreatedEvent)); - - // Act - var result = _messages.GetHeader(typeof(OrderCreatedEvent)); - - // Assert - Assert.That(result, Is.EqualTo("endpoint=OrderService, type=Event, name=OrderCreated, version=2.1.3")); - } - - [Test] - public void GetHeader_NonExistentType_ReturnsNull() - { - // Act - var result = _messages.GetHeader(typeof(CreateOrderCommand)); - - // Assert - Assert.That(result, Is.Null); - } - - [Test] - public void GetTypeByHeader_ValidHeader_ReturnsCorrectType() - { - // Arrange - _messages.Add(typeof(ProcessPaymentCommand)); - var header = "endpoint=PaymentService, type=Command, name=ProcessPayment, version=1.5.2"; - - // Act - var result = _messages.GetTypeByHeader(header); - - // Assert - Assert.That(result, Is.EqualTo(typeof(ProcessPaymentCommand))); - } - - [Test] - public void GetTypeByHeader_InvalidHeader_ReturnsNull() - { - // Arrange - const string InvalidHeader = "invalid header format"; - - // Act - var result = _messages.GetTypeByHeader(InvalidHeader); - - // Assert - Assert.That(result, Is.Null); + // Act & Assert + Assert.Throws(() => _messages.GetMessageInfo(typeof(Command))); } [Test] - public void GetTypeByHeader_NonExistentMessage_ReturnsNull() + public void GetTypeByMessageInfo_ExistingMessageInfo_ReturnsCorrectType() { // Arrange - const string Header = "endpoint=UnknownService, type=Command, name=UnknownCommand, version=1.0.0"; + _messages.Add(typeof(Event)); + var messageInfo = new MessageInfo(MessageType.Event, "polybus", "polybus-event", 2, 1, 3); // Act - var result = _messages.GetTypeByHeader(Header); + var result = _messages.GetTypeByMessageInfo(messageInfo); // Assert - Assert.That(result, Is.Null); + Assert.That(result, Is.EqualTo(typeof(Event))); } [Test] - public void GetTypeByHeader_CachesResults() + public void GetTypeByMessageInfo_NonExistentMessageInfo_ThrowsError() { // Arrange - _messages.Add(typeof(CreateOrderCommand)); - const string Header = "endpoint=OrderService, type=Command, name=CreateOrder, version=1.0.0"; - - // Act - var result1 = _messages.GetTypeByHeader(Header); - var result2 = _messages.GetTypeByHeader(Header); + var messageInfo = new MessageInfo(MessageType.Command, "unknown", "unknown-command", 1, 0, 0); - // Assert - Assert.That(result1, Is.EqualTo(typeof(CreateOrderCommand))); - Assert.That(result2, Is.EqualTo(typeof(CreateOrderCommand))); - Assert.That(ReferenceEquals(result1, result2), Is.True); + // Act & Assert + Assert.Throws(() => _messages.GetTypeByMessageInfo(messageInfo)); } [Test] - public void GetTypeByMessageInfo_ExistingMessageInfo_ReturnsCorrectType() + public void GetTypeByMessageInfo_DifferentMinorPatchVersions_ReturnsType() { // Arrange - _messages.Add(typeof(OrderCreatedEvent)); - var messageInfo = new MessageInfo(MessageType.Event, "OrderService", "OrderCreated", 2, 1, 3); + _messages.Add(typeof(Event)); // Has version 2.1.3 + var messageInfoDifferentMinor = new MessageInfo(MessageType.Event, "polybus", "polybus-event", 2, 5, 3); + var messageInfoDifferentPatch = new MessageInfo(MessageType.Event, "polybus", "polybus-event", 2, 1, 9); // Act - var result = _messages.GetTypeByMessageInfo(messageInfo); + var result1 = _messages.GetTypeByMessageInfo(messageInfoDifferentMinor); + var result2 = _messages.GetTypeByMessageInfo(messageInfoDifferentPatch); // Assert - Assert.That(result, Is.EqualTo(typeof(OrderCreatedEvent))); + Assert.That(result1, Is.EqualTo(typeof(Event))); + Assert.That(result2, Is.EqualTo(typeof(Event))); } [Test] - public void GetTypeByMessageInfo_NonExistentMessageInfo_ReturnsNull() + public void GetTypeByMessageInfo_DifferentMajorVersion_ThrowsError() { // Arrange - var messageInfo = new MessageInfo(MessageType.Command, "UnknownService", "UnknownCommand", 1, 0, 0); - - // Act - var result = _messages.GetTypeByMessageInfo(messageInfo); + _messages.Add(typeof(Event)); // Has version 2.1.3 + var messageInfoDifferentMajor = new MessageInfo(MessageType.Event, "polybus", "polybus-event", 3, 1, 3); - // Assert - Assert.That(result, Is.Null); + // Act & Assert + Assert.Throws(() => _messages.GetTypeByMessageInfo(messageInfoDifferentMajor)); } [Test] - public void GetTypeByMessageInfo_DifferentMinorPatchVersions_ReturnsType() + public void Add_SameTypeTwice_ThrowsError() { // Arrange - _messages.Add(typeof(OrderCreatedEvent)); // Has version 2.1.3 - var messageInfoDifferentMinor = new MessageInfo(MessageType.Event, "OrderService", "OrderCreated", 2, 5, 3); - var messageInfoDifferentPatch = new MessageInfo(MessageType.Event, "OrderService", "OrderCreated", 2, 1, 9); - - // Act - var result1 = _messages.GetTypeByMessageInfo(messageInfoDifferentMinor); - var result2 = _messages.GetTypeByMessageInfo(messageInfoDifferentPatch); + _messages.Add(typeof(Command)); - // Assert - Assert.That(result1, Is.EqualTo(typeof(OrderCreatedEvent))); - Assert.That(result2, Is.EqualTo(typeof(OrderCreatedEvent))); + // Act & Assert + Assert.Throws(() => _messages.Add(typeof(Command))); } [Test] - public void GetTypeByMessageInfo_DifferentMajorVersion_ReturnsNull() + public void GetHeaderByMessageInfo_ExistingMessageInfo_ReturnsCorrectHeader() { // Arrange - _messages.Add(typeof(OrderCreatedEvent)); // Has version 2.1.3 - var messageInfoDifferentMajor = new MessageInfo(MessageType.Event, "OrderService", "OrderCreated", 3, 1, 3); + _messages.Add(typeof(Command)); + var messageInfo = new MessageInfo(MessageType.Command, "polybus", "polybus-command", 1, 0, 0); // Act - var result = _messages.GetTypeByMessageInfo(messageInfoDifferentMajor); + var result = _messages.GetHeaderByMessageInfo(messageInfo); // Assert - Assert.That(result, Is.Null); + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.Not.Empty); + Assert.That(result, Is.EqualTo(messageInfo.ToString(true))); } [Test] - public void MultipleMessages_AllMethodsWorkCorrectly() + public void GetHeaderByMessageInfo_NonExistentMessageInfo_ThrowsError() { // Arrange - _messages.Add(typeof(CreateOrderCommand)); - _messages.Add(typeof(OrderCreatedEvent)); - _messages.Add(typeof(ProcessPaymentCommand)); + var messageInfo = new MessageInfo(MessageType.Command, "unknown", "unknown-command", 1, 0, 0); - // Act & Assert - GetMessageInfo - var commandInfo = _messages.GetMessageInfo(typeof(CreateOrderCommand)); - var eventInfo = _messages.GetMessageInfo(typeof(OrderCreatedEvent)); - var paymentInfo = _messages.GetMessageInfo(typeof(ProcessPaymentCommand)); - - Assert.That(commandInfo?.Type, Is.EqualTo(MessageType.Command)); - Assert.That(eventInfo?.Type, Is.EqualTo(MessageType.Event)); - Assert.That(paymentInfo?.Endpoint, Is.EqualTo("PaymentService")); - - // Act & Assert - GetHeader - var commandHeader = _messages.GetHeader(typeof(CreateOrderCommand)); - var eventHeader = _messages.GetHeader(typeof(OrderCreatedEvent)); - - Assert.That(commandHeader, Does.Contain("OrderService")); - Assert.That(eventHeader, Does.Contain("OrderCreated")); - - // Act & Assert - GetTypeByHeader - var typeFromHeader = _messages.GetTypeByHeader(commandHeader!); - Assert.That(typeFromHeader, Is.EqualTo(typeof(CreateOrderCommand))); + // Act & Assert + Assert.Throws(() => _messages.GetHeaderByMessageInfo(messageInfo)); } [Test] - public void Add_SameTypeTwice_ThrowsArgumentException() + public void GetHeaderByMessageInfo_DifferentMajorVersion_ThrowsError() { // Arrange - _messages.Add(typeof(CreateOrderCommand)); + _messages.Add(typeof(Event)); // Has version 2.1.3 + var messageInfoDifferentMajor = new MessageInfo(MessageType.Event, "polybus", "polybus-event", 3, 1, 3); // Act & Assert - Assert.Throws(() => _messages.Add(typeof(CreateOrderCommand))); + Assert.Throws(() => _messages.GetHeaderByMessageInfo(messageInfoDifferentMajor)); } } diff --git a/src/dotnet/PolyBus/Headers.cs b/src/dotnet/PolyBus/Headers.cs index 3e144c7..7c3e20f 100644 --- a/src/dotnet/PolyBus/Headers.cs +++ b/src/dotnet/PolyBus/Headers.cs @@ -8,6 +8,11 @@ namespace PolyBus; [DebuggerStepThrough] public static class Headers { + /// + /// The correlation id header name used for specifying the correlation identifier for tracking related messages. + /// + public const string CorrelationId = "correlation-id"; + /// /// The content type header name used for specifying the message content type (e.g., "application/json"). /// @@ -17,4 +22,9 @@ public static class Headers /// The message type header name used for specifying the type of the message. /// public const string MessageType = "x-type"; + + /// + /// The message id header name used for specifying the unique identifier of the message. + /// + public const string RequestId = "request-id"; } diff --git a/src/dotnet/PolyBus/IPolyBus.cs b/src/dotnet/PolyBus/IPolyBus.cs index a59811f..ae8af93 100644 --- a/src/dotnet/PolyBus/IPolyBus.cs +++ b/src/dotnet/PolyBus/IPolyBus.cs @@ -11,13 +11,15 @@ public interface IPolyBus ITransport Transport { get; } - IList IncomingHandlers { get; } + IList IncomingPipeline { get; } - IList OutgoingHandlers { get; } + IList OutgoingPipeline { get; } Messages Messages { get; } - Task CreateTransaction(IncomingMessage? message = null); + Task CreateIncomingTransaction(IncomingMessage message); + + Task CreateOutgoingTransaction(); Task Send(Transaction transaction); diff --git a/src/dotnet/PolyBus/PolyBus.cs b/src/dotnet/PolyBus/PolyBus.cs index 99a6f6f..7c37334 100644 --- a/src/dotnet/PolyBus/PolyBus.cs +++ b/src/dotnet/PolyBus/PolyBus.cs @@ -11,22 +11,25 @@ public class PolyBus(PolyBusBuilder builder) : IPolyBus public ITransport Transport { get; set; } = null!; - public IList IncomingHandlers { get; } = builder.IncomingHandlers; + public IList IncomingPipeline { get; } = builder.IncomingPipeline; - public IList OutgoingHandlers { get; } = builder.OutgoingHandlers; + public IList OutgoingPipeline { get; } = builder.OutgoingPipeline; public Messages Messages { get; } = builder.Messages; - public Task CreateTransaction(IncomingMessage? message = null) => - builder.TransactionFactory(builder, this, message); + public Task CreateIncomingTransaction(IncomingMessage message) => + builder.IncomingTransactionFactory(builder, this, message); + + public Task CreateOutgoingTransaction() => + builder.OutgoingTransactionFactory(builder, this); public async Task Send(Transaction transaction) { - var step = () => Transport.Send(transaction); + var step = () => Transport.Handle(transaction); if (transaction is IncomingTransaction incomingTransaction) { - var handlers = transaction.Bus.IncomingHandlers; + var handlers = transaction.Bus.IncomingPipeline; for (var index = handlers.Count - 1; index >= 0; index--) { var handler = handlers[index]; @@ -36,7 +39,7 @@ public async Task Send(Transaction transaction) } else if (transaction is OutgoingTransaction outgoingTransaction) { - var handlers = transaction.Bus.OutgoingHandlers; + var handlers = transaction.Bus.OutgoingPipeline; for (var index = handlers.Count - 1; index >= 0; index--) { var handler = handlers[index]; diff --git a/src/dotnet/PolyBus/PolyBusBuilder.cs b/src/dotnet/PolyBus/PolyBusBuilder.cs index 9fb10b4..1b72a7c 100644 --- a/src/dotnet/PolyBus/PolyBusBuilder.cs +++ b/src/dotnet/PolyBus/PolyBusBuilder.cs @@ -9,15 +9,16 @@ namespace PolyBus; public class PolyBusBuilder { /// - /// The transaction factory will be used to create transactions for message handling. - /// Transactions are used to ensure that a group of message related to a single request - /// are sent to the transport in a single atomic operation. + /// The incoming transaction factory will be used to create incoming transactions for handling messages. /// - public TransactionFactory TransactionFactory { get; set; } = (_, bus, message) => - Task.FromResult( - message != null - ? new IncomingTransaction(bus, message) - : new OutgoingTransaction(bus)); + public IncomingTransactionFactory IncomingTransactionFactory { get; set; } = (_, bus, message) => + Task.FromResult(new IncomingTransaction(bus, message)); + + /// + /// The outgoing transaction factory will be used to create outgoing transactions for sending messages. + /// + public OutgoingTransactionFactory OutgoingTransactionFactory { get; set; } = (_, bus) => + Task.FromResult(new OutgoingTransaction(bus)); /// /// The transport factory will be used to create the transport for the PolyBus instance. @@ -25,20 +26,20 @@ public class PolyBusBuilder /// public TransportFactory TransportFactory { get; set; } = (builder, bus) => { - var transport = new InMemoryTransport(); + var transport = new InMemoryMessageBroker(); - return Task.FromResult(transport.AddEndpoint(builder, bus)); + return transport.AddEndpoint(builder, bus); }; - public Dictionary Properties { get; } = []; + public IDictionary Properties { get; } = new Dictionary(); - public IList IncomingHandlers { get; } = []; + public IList IncomingPipeline { get; } = []; - public IList OutgoingHandlers { get; } = []; + public IList OutgoingPipeline { get; } = []; public Messages Messages { get; } = new(); - public string Name { get; set; } = "PolyBusInstance"; + public string Name { get; set; } = "polybus"; public virtual async Task Build() { diff --git a/src/dotnet/PolyBus/PolyBusError.cs b/src/dotnet/PolyBus/PolyBusError.cs new file mode 100644 index 0000000..0cb53c7 --- /dev/null +++ b/src/dotnet/PolyBus/PolyBusError.cs @@ -0,0 +1,6 @@ +namespace PolyBus; + +public class PolyBusError(int errorCode, string message) : Exception(message) +{ + public int ErrorCode => errorCode; +} diff --git a/src/dotnet/PolyBus/Transport/ITransport.cs b/src/dotnet/PolyBus/Transport/ITransport.cs index 4db4e8d..7d13740 100644 --- a/src/dotnet/PolyBus/Transport/ITransport.cs +++ b/src/dotnet/PolyBus/Transport/ITransport.cs @@ -8,16 +8,25 @@ namespace PolyBus.Transport; /// public interface ITransport { - bool SupportsDelayedMessages { get; } + /// + /// Where messages that cannot be delivered are sent. + /// + string DeadLetterEndpoint { get; } - bool SupportsCommandMessages { get; } + /// + /// Sends messages associated with the given transaction to the transport. + /// + Task Handle(Transaction transaction); - bool SupportsSubscriptions { get; } + /// + /// If the transport supports sending delayed commands, this will be true. + /// + bool SupportsDelayedCommands { get; } /// - /// Sends messages associated with the given transaction to the transport. + /// If the transport supports sending command messages, this will be true. /// - Task Send(Transaction transaction); + bool SupportsCommandMessages { get; } /// /// Subscribes to a messages so that the transport can start receiving them. @@ -25,7 +34,12 @@ public interface ITransport Task Subscribe(MessageInfo messageInfo); /// - /// Enables the transport to start processing messages. + /// If the transport supports event message subscriptions, this will be true. + /// + bool SupportsSubscriptions { get; } + + /// + /// Starts the transport to start processing messages. /// Task Start(); diff --git a/src/dotnet/PolyBus/Transport/InMemory/InMemoryEndpoint.cs b/src/dotnet/PolyBus/Transport/InMemory/InMemoryEndpoint.cs new file mode 100644 index 0000000..ab6008f --- /dev/null +++ b/src/dotnet/PolyBus/Transport/InMemory/InMemoryEndpoint.cs @@ -0,0 +1,83 @@ +using System.Collections.Concurrent; +using PolyBus.Transport.Transactions; +using PolyBus.Transport.Transactions.Messages; + +namespace PolyBus.Transport.InMemory; + +/// +/// An implementation of an in-memory transport endpoint. +/// +public class InMemoryEndpoint(InMemoryMessageBroker broker, IPolyBus bus) : ITransport +{ + /// + /// The associated PolyBus instance. + /// + public IPolyBus Bus => bus; + + public Action? DeadLetterHandler { get; set; } + + public bool Active { get; private set; } + + public string DeadLetterEndpoint => $"{bus.Name}.dead.letters"; + + /// + /// If active, handles an incoming message by creating a transaction and executing the handlers for the bus. + /// + public async Task HandleMessage(IncomingMessage message, bool isDeadLetter) + { + if (Active) + { + if (isDeadLetter) + { + DeadLetterHandler?.Invoke(message); + } + else + { + var transaction = await bus.CreateIncomingTransaction(message); + + await bus.Send(transaction); + } + } + } + + public Task Handle(Transaction transaction) + { + if (!Active) + { + throw new PolyBusNotStartedError(); + } + + broker.Send(transaction); + + return Task.CompletedTask; + } + public bool SupportsDelayedCommands => true; + public bool SupportsCommandMessages => true; + + public Task Subscribe(MessageInfo messageInfo) + { + if (!Active) + { + throw new PolyBusNotStartedError(); + } + + _subscriptions[messageInfo.ToString(false)] = true; + + return Task.CompletedTask; + } + public bool IsSubscribed(MessageInfo messageInfo) => _subscriptions.ContainsKey(messageInfo.ToString(false)); + public bool SupportsSubscriptions => true; + readonly ConcurrentDictionary _subscriptions = new(); + + public Task Start() + { + Active = true; + return Task.CompletedTask; + } + + public Task Stop() + { + Active = false; + return Task.CompletedTask; + } +} diff --git a/src/dotnet/PolyBus/Transport/InMemory/InMemoryMessageBroker.cs b/src/dotnet/PolyBus/Transport/InMemory/InMemoryMessageBroker.cs new file mode 100644 index 0000000..7190289 --- /dev/null +++ b/src/dotnet/PolyBus/Transport/InMemory/InMemoryMessageBroker.cs @@ -0,0 +1,139 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using PolyBus.Transport.Transactions; +using PolyBus.Transport.Transactions.Messages; + +namespace PolyBus.Transport.InMemory; + +/// +/// A message broker that uses in-memory transport for message passing. +/// +public class InMemoryMessageBroker +{ + /// + /// The collection of in-memory endpoints managed by this broker. + /// + public ConcurrentDictionary Endpoints { get; } = new(); + + /// + /// The logger for the InMemoryMessageBroker. + /// + public ILogger Log { get; set; } = NullLogger.Instance; + + /// + /// The ITransport factory method. + /// + public Task AddEndpoint(PolyBusBuilder builder, IPolyBus bus) + { + var endpoint = new InMemoryEndpoint(this, bus); + Endpoints[bus.Name] = endpoint; + return Task.FromResult(endpoint); + } + + /// + /// Processes the transaction and distributes outgoing messages to the appropriate endpoints. + /// + public async void Send(Transaction transaction) + { + if (transaction.OutgoingMessages.Count == 0) + { + return; + } + + try + { + await Task.Yield(); + + if (Interlocked.Increment(ref _count) == 1) + { + while (_emptySignal.CurrentCount > 0) + { + await _emptySignal.WaitAsync(0); + } + } + + var tasks = new List(); + var now = DateTime.UtcNow; + + foreach (var message in transaction.OutgoingMessages) + { + foreach (var endpoint in Endpoints.Values) + { + var isDeadLetter = endpoint.DeadLetterEndpoint == message.Endpoint; + if (isDeadLetter + || endpoint.Bus.Name == message.Endpoint + || (message.Endpoint == null + && (message.MessageInfo.Endpoint == endpoint.Bus.Name + || endpoint.IsSubscribed(message.MessageInfo)))) + { + var incomingMessage = new IncomingMessage(endpoint.Bus, message.Body, message.MessageInfo) + { + Headers = new Dictionary(message.Headers) + }; + if (message.DeliverAt != null) + { + var wait = message.DeliverAt.Value - now; + if (wait > TimeSpan.Zero) + { + DelayedSend(endpoint, incomingMessage, wait, isDeadLetter); + continue; + } + } + + var task = endpoint.HandleMessage(incomingMessage, isDeadLetter); + tasks.Add(task); + } + } + } + + await Task.WhenAll(tasks); + } + catch (Exception error) + { + Log.LogError(error, error.Message); + } + finally + { + if (Interlocked.Decrement(ref _count) == 0) + { + _emptySignal.Release(); + } + } + } + async void DelayedSend(InMemoryEndpoint endpoint, IncomingMessage message, TimeSpan delay, bool isDeadLetter) + { + try + { + await Task.Delay(delay, _cts.Token); + await endpoint.HandleMessage(message, isDeadLetter); + } + catch (OperationCanceledException) + { + // Ignore cancellation + } + catch (Exception error) + { + Log.LogError(error, error.Message); + } + } + + /// + /// Stops all endpoints and waits for in-flight messages to be processed. + /// + public async Task Stop() + { + foreach (var endpoint in Endpoints.Values) + { + await endpoint.Stop(); + } + _cts.Cancel(); + if (Volatile.Read(ref _count) > 0) + { + await _emptySignal.WaitAsync(); + } + } + int _count; + readonly CancellationTokenSource _cts = new(); + readonly SemaphoreSlim _emptySignal = new(0, 1); +} diff --git a/src/dotnet/PolyBus/Transport/InMemory/InMemoryTransport.cs b/src/dotnet/PolyBus/Transport/InMemory/InMemoryTransport.cs deleted file mode 100644 index 20dcd23..0000000 --- a/src/dotnet/PolyBus/Transport/InMemory/InMemoryTransport.cs +++ /dev/null @@ -1,159 +0,0 @@ -using Microsoft.Extensions.Logging; -using PolyBus.Transport.Transactions; -using PolyBus.Transport.Transactions.Messages; - -namespace PolyBus.Transport.InMemory; - -public class InMemoryTransport -{ - public ITransport AddEndpoint(PolyBusBuilder builder, IPolyBus bus) - { - var endpoint = new Endpoint(this, bus); - _endpoints.Add(bus.Name, endpoint); - return endpoint; - } - readonly Dictionary _endpoints = []; - class Endpoint(InMemoryTransport transport, IPolyBus bus) : ITransport - { - public async Task Handle(OutgoingMessage message) - { - if (!transport.UseSubscriptions || _subscriptions.Contains(message.MessageType)) - { - var incomingMessage = new IncomingMessage(bus, message.Body) - { - Headers = message.Headers - }; - - try - { - var transaction = (IncomingTransaction)await bus.CreateTransaction(incomingMessage); - - await transaction.Commit(); - } - catch (Exception error) - { - var logger = incomingMessage.State.Values.OfType().FirstOrDefault(); - logger?.LogError(error, error.Message); - } - } - } - - readonly List _subscriptions = []; - public Task Subscribe(MessageInfo messageInfo) - { - var type = bus.Messages.GetTypeByMessageInfo(messageInfo) - ?? throw new ArgumentException($"Message type for attribute {messageInfo} is not registered."); - _subscriptions.Add(type); - return Task.CompletedTask; - } - - public bool SupportsCommandMessages => true; - - public bool SupportsDelayedMessages => true; - - public bool SupportsSubscriptions => true; - - public Task Send(Transaction transaction) => transport.Send(transaction); - - public Task Start() => transport.Start(); - - public Task Stop() => transport.Stop(); - } - - public async Task Send(Transaction transaction) - { - if (!_active) - { - throw new InvalidOperationException("Transport is not active."); - } - - if (transaction.OutgoingMessages.Count == 0) - { - return; - } - - if (Interlocked.Increment(ref _count) == 1) - { - while (_emptySignal.CurrentCount > 0) - { - await _emptySignal.WaitAsync(0); - } - } - - try - { - var tasks = new List(); - var now = DateTime.UtcNow; - - foreach (var message in transaction.OutgoingMessages) - { - if (message.DeliverAt != null) - { - var wait = message.DeliverAt.Value - now; - if (wait > TimeSpan.Zero) - { - DelayedSendAsync(message, wait); - continue; - } - } - - foreach (var endpoint in _endpoints.Values) - { - var task = endpoint.Handle(message); - tasks.Add(task); - } - } - - await Task.WhenAll(tasks); - } - finally - { - if (Interlocked.Decrement(ref _count) == 0) - { - _emptySignal.Release(); - } - } - } - async void DelayedSendAsync(OutgoingMessage message, TimeSpan delay) - { - try - { - await Task.Delay(delay, _cts.Token); - var transaction = (OutgoingTransaction)await message.Bus.CreateTransaction(); - message.DeliverAt = null; - transaction.OutgoingMessages.Add(message); - await Send(transaction); - } - catch (OperationCanceledException) - { - // Ignore cancellation - } - catch (Exception error) - { - var logger = message.State.Values.OfType().FirstOrDefault(); - logger?.LogError(error, error.Message); - } - } - - public bool UseSubscriptions { get; set; } - - public Task Start() - { - _active = true; - return Task.CompletedTask; - } - bool _active; - - public async Task Stop() - { - _active = false; - _cts.Cancel(); - if (Volatile.Read(ref _count) > 0) - { - await _emptySignal.WaitAsync(); - } - } - int _count; - readonly CancellationTokenSource _cts = new(); - readonly SemaphoreSlim _emptySignal = new(0, 1); -} diff --git a/src/dotnet/PolyBus/Transport/PolyBusNotStartedError.cs b/src/dotnet/PolyBus/Transport/PolyBusNotStartedError.cs new file mode 100644 index 0000000..356456d --- /dev/null +++ b/src/dotnet/PolyBus/Transport/PolyBusNotStartedError.cs @@ -0,0 +1,7 @@ +namespace PolyBus.Transport; + +/// +/// A PolyBus error indicating that the bus has not been started. +/// +public class PolyBusNotStartedError() + : PolyBusError(1, "PolyBus has not been started. Please call IPolyBus.Start() before using the bus."); diff --git a/src/dotnet/PolyBus/Transport/Transactions/TransactionFactory.cs b/src/dotnet/PolyBus/Transport/Transactions/IncomingTransactionFactory.cs similarity index 71% rename from src/dotnet/PolyBus/Transport/Transactions/TransactionFactory.cs rename to src/dotnet/PolyBus/Transport/Transactions/IncomingTransactionFactory.cs index 5208772..679b72d 100644 --- a/src/dotnet/PolyBus/Transport/Transactions/TransactionFactory.cs +++ b/src/dotnet/PolyBus/Transport/Transactions/IncomingTransactionFactory.cs @@ -7,4 +7,4 @@ namespace PolyBus.Transport.Transactions; /// This should be used to integrate with external transaction systems to ensure message processing /// is done within the context of a transaction. /// -public delegate Task TransactionFactory(PolyBusBuilder builder, IPolyBus bus, IncomingMessage? message = null); +public delegate Task IncomingTransactionFactory(PolyBusBuilder builder, IPolyBus bus, IncomingMessage message); diff --git a/src/dotnet/PolyBus/Transport/Transactions/Messages/Handlers/Error/ErrorHandler.cs b/src/dotnet/PolyBus/Transport/Transactions/Messages/Handlers/Error/ErrorHandler.cs index c82390e..07e1003 100644 --- a/src/dotnet/PolyBus/Transport/Transactions/Messages/Handlers/Error/ErrorHandler.cs +++ b/src/dotnet/PolyBus/Transport/Transactions/Messages/Handlers/Error/ErrorHandler.cs @@ -1,25 +1,59 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + namespace PolyBus.Transport.Transactions.Messages.Handlers.Error; +/// +/// A handler for processing message errors with retry logic. +/// public class ErrorHandler { - public const string ErrorMessageHeader = "X-Error-Message"; - public const string ErrorStackTraceHeader = "X-Error-Stack-Trace"; - public const string RetryCountHeader = "X-Retry-Count"; + /// + /// The logger instance to use for logging. + /// + public ILogger Log { get; set; } = NullLogger.Instance; - public int Delay { get; set; } = 30; + /// + /// The delay increment in seconds for each delayed retry attempt. + /// The delay is calculated as: delay attempt number * delay increment. + /// + public int DelayIncrement { get; set; } = 30; + /// + /// How many delayed retry attempts to make before sending to the dead-letter queue. + /// public int DelayedRetryCount { get; set; } = 3; + /// + /// How many immediate retry attempts to make before applying delayed retries. + /// public int ImmediateRetryCount { get; set; } = 3; - public string? DeadLetterEndpoint { get; set; } + /// + /// The header key for storing error messages in dead-lettered messages. + /// + public string ErrorMessageHeader { get; set; } = "x-error-message"; + + /// + /// The header key for storing error stack traces in dead-lettered messages. + /// + public string ErrorStackTraceHeader { get; set; } = "x-error-stack-trace"; + + /// + /// The header key for storing the delayed retry count. + /// + public string RetryCountHeader { get; set; } = "x-retry-count"; - public async Task Retrier(IncomingTransaction transaction, Func next) + /// + /// Retries the processing of a message according to the configured retry logic. + /// + public virtual async Task Retrier(IncomingTransaction transaction, Func next) { - var delayedAttempt = transaction.IncomingMessage.Headers.TryGetValue(RetryCountHeader, out var headerValue) - && byte.TryParse(headerValue, out var parsedHeaderValue) - ? parsedHeaderValue - : 0; + var delayedAttempt = + transaction.IncomingMessage.Headers.TryGetValue(RetryCountHeader, out var headerValue) + && byte.TryParse(headerValue, out var parsedHeaderValue) + ? parsedHeaderValue + : 0; var delayedRetryCount = Math.Max(1, DelayedRetryCount); var immediateRetryCount = Math.Max(1, ImmediateRetryCount); @@ -32,6 +66,12 @@ public async Task Retrier(IncomingTransaction transaction, Func next) } catch (Exception error) { + Log.LogError("Error processing message {MessageInfo} (immediate attempts: {immediateAttempt}, delayed attempts: {delayedAttempt}): {ErrorMessage}", + immediateAttempt, + delayedAttempt, + transaction.IncomingMessage.MessageInfo, + error.Message); + transaction.OutgoingMessages.Clear(); if (immediateAttempt < immediateRetryCount - 1) @@ -39,27 +79,42 @@ public async Task Retrier(IncomingTransaction transaction, Func next) continue; } - if (delayedAttempt < delayedRetryCount) + if (transaction.IncomingMessage.Bus.Transport.SupportsDelayedCommands + && delayedAttempt < delayedRetryCount) { // Re-queue the message with a delay delayedAttempt++; - - var delayedMessage = transaction.AddOutgoingMessage( - transaction.IncomingMessage, - transaction.Bus.Name); - delayedMessage.DeliverAt = GetNextRetryTime(delayedAttempt); + var delayedMessage = new OutgoingMessage( + transaction.Bus, + transaction.IncomingMessage.Message, + transaction.Bus.Name, + transaction.IncomingMessage.MessageInfo) + { + DeliverAt = GetNextRetryTime(delayedAttempt), + Headers = transaction.IncomingMessage.Headers + .ToDictionary(it => it.Key, it => it.Value), + }; delayedMessage.Headers[RetryCountHeader] = delayedAttempt.ToString(); + transaction.OutgoingMessages.Add(delayedMessage); continue; } - var deadLetterEndpoint = DeadLetterEndpoint ?? $"{transaction.Bus.Name}.Errors"; - var deadLetterMessage = transaction.AddOutgoingMessage(transaction.IncomingMessage, deadLetterEndpoint); + var deadLetterMessage = new OutgoingMessage( + transaction.Bus, + transaction.IncomingMessage.Message, + transaction.Bus.Transport.DeadLetterEndpoint, + transaction.IncomingMessage.MessageInfo) + { + Headers = transaction.IncomingMessage.Headers + .ToDictionary(it => it.Key, it => it.Value), + }; deadLetterMessage.Headers[ErrorMessageHeader] = error.Message; deadLetterMessage.Headers[ErrorStackTraceHeader] = error.StackTrace ?? string.Empty; + transaction.OutgoingMessages.Add(deadLetterMessage); } } } - public virtual DateTime GetNextRetryTime(int attempt) => DateTime.UtcNow.AddSeconds(attempt * Delay); + public virtual DateTime GetNextRetryTime(int attempt) => DateTime.UtcNow.AddSeconds(attempt * DelayIncrement); } diff --git a/src/dotnet/PolyBus/Transport/Transactions/Messages/Handlers/Serializers/JsonHandlers.cs b/src/dotnet/PolyBus/Transport/Transactions/Messages/Handlers/Serializers/JsonHandlers.cs index 5bdf85d..a4bfeb3 100644 --- a/src/dotnet/PolyBus/Transport/Transactions/Messages/Handlers/Serializers/JsonHandlers.cs +++ b/src/dotnet/PolyBus/Transport/Transactions/Messages/Handlers/Serializers/JsonHandlers.cs @@ -1,61 +1,48 @@ using System.Text.Json; -using System.Text.Json.Nodes; namespace PolyBus.Transport.Transactions.Messages.Handlers.Serializers; +/// +/// Handlers for serializing and deserializing messages as JSON. +/// public class JsonHandlers { + /// + /// The options to use for the JSON serializer. + /// public JsonSerializerOptions? JsonSerializerOptions { get; set; } + /// + /// The content type to set on outgoing messages. + /// public string ContentType { get; set; } = "application/json"; /// - /// If the type header is missing, invalid, or if the type cannot be found, throw an exception. + /// The header key to use for the content type. /// - public bool ThrowOnMissingType { get; set; } = true; + public string Header { get; set; } = Headers.ContentType; - public Task Deserializer(IncomingTransaction transaction, Func next) + /// + /// Deserializes incoming messages from JSON. + /// + public virtual Task Deserializer(IncomingTransaction transaction, Func next) { - var message = transaction.IncomingMessage; + var incomingMessage = transaction.IncomingMessage; - var type = !message.Headers.TryGetValue(Headers.MessageType, out var header) - ? null - : message.Bus.Messages.GetTypeByHeader(header); - - if (type == null && ThrowOnMissingType) - { - throw new InvalidOperationException("The type header is missing, invalid, or if the type cannot be found."); - } - - message.Message = type == null - ? JsonNode.Parse(message.Body)! - : JsonSerializer.Deserialize(message.Body, type, JsonSerializerOptions)!; + incomingMessage.Message = JsonSerializer.Deserialize(incomingMessage.Body, incomingMessage.MessageType, JsonSerializerOptions)!; return next(); } /// - /// If the message type is not in the list of known messages, throw an exception. + /// Serializes outgoing messages to JSON. /// - public bool ThrowOnInvalidType { get; set; } = true; - - public Task Serializer(OutgoingTransaction transaction, Func next) + public virtual Task Serializer(OutgoingTransaction transaction, Func next) { foreach (var message in transaction.OutgoingMessages) { message.Body = JsonSerializer.Serialize(message.Message, JsonSerializerOptions); - message.Headers[Headers.ContentType] = ContentType; - - var header = message.Bus.Messages.GetHeader(message.MessageType); - - if (header != null) - { - message.Headers[Headers.MessageType] = header; - } - else if (ThrowOnInvalidType) - { - throw new InvalidOperationException("The header has an valid type."); - } + message.Headers[Header] = ContentType; } return next(); } diff --git a/src/dotnet/PolyBus/Transport/Transactions/Messages/IncomingMessage.cs b/src/dotnet/PolyBus/Transport/Transactions/Messages/IncomingMessage.cs index c5cd641..1b241a4 100644 --- a/src/dotnet/PolyBus/Transport/Transactions/Messages/IncomingMessage.cs +++ b/src/dotnet/PolyBus/Transport/Transactions/Messages/IncomingMessage.cs @@ -3,12 +3,17 @@ namespace PolyBus.Transport.Transactions.Messages; [DebuggerStepThrough] -public class IncomingMessage(IPolyBus bus, string body, object? message = null, Type? messageType = null) : Message(bus) +public class IncomingMessage(IPolyBus bus, string body, MessageInfo messageInfo) : Message(bus) { + /// + /// The message info describing metadata about the message. + /// + public virtual MessageInfo MessageInfo { get; set; } = messageInfo ?? throw new ArgumentNullException(nameof(messageInfo)); + /// /// The default is string, but can be changed based on deserialization. /// - public virtual Type MessageType { get; set; } = messageType ?? typeof(string); + public virtual Type MessageType { get; set; } = bus.Messages.GetTypeByMessageInfo(messageInfo); /// /// The message body contents. @@ -18,5 +23,5 @@ public class IncomingMessage(IPolyBus bus, string body, object? message = null, /// /// The deserialized message object, otherwise the same value as Body. /// - public virtual object Message { get; set; } = message ?? body; + public virtual object Message { get; set; } = body; } diff --git a/src/dotnet/PolyBus/Transport/Transactions/Messages/Message.cs b/src/dotnet/PolyBus/Transport/Transactions/Messages/Message.cs index 636bb5e..a103f12 100644 --- a/src/dotnet/PolyBus/Transport/Transactions/Messages/Message.cs +++ b/src/dotnet/PolyBus/Transport/Transactions/Messages/Message.cs @@ -18,5 +18,5 @@ public class Message(IPolyBus bus) /// /// The bus instance associated with the message. /// - public IPolyBus Bus => bus ?? throw new ArgumentNullException(nameof(bus)); + public virtual IPolyBus Bus => bus ?? throw new ArgumentNullException(nameof(bus)); } diff --git a/src/dotnet/PolyBus/Transport/Transactions/Messages/Messages.cs b/src/dotnet/PolyBus/Transport/Transactions/Messages/Messages.cs index 03273bf..ebd55ed 100644 --- a/src/dotnet/PolyBus/Transport/Transactions/Messages/Messages.cs +++ b/src/dotnet/PolyBus/Transport/Transactions/Messages/Messages.cs @@ -1,45 +1,41 @@ -using System.Collections.Concurrent; using System.Reflection; namespace PolyBus.Transport.Transactions.Messages; /// -/// A collection of message types and their associated message headers. +/// A collection of message types and their associated message headers and attributes. /// public class Messages { - readonly ConcurrentDictionary _map = new(); - readonly Dictionary _types = []; + protected Dictionary Types { get; } = []; /// /// Gets the message attribute associated with the specified type. /// - public virtual MessageInfo? GetMessageInfo(Type type) => - _types.TryGetValue(type, out var value) ? value.attribute : null; - - /// - /// Attempts to get the message type associated with the specified header. - /// /// - /// If found, returns the message type; otherwise, returns null. + /// The MessageInfoAttribute associated with the specified type. /// - public virtual Type? GetTypeByHeader(string header) - { - var attribute = MessageInfo.GetAttributeFromHeader(header); - return attribute == null ? null : _map.GetOrAdd(header, _ => _types - .Where(pair => pair.Value.attribute.Equals(attribute)) - .Select(pair => pair.Key) - .FirstOrDefault()); - } + /// + /// If no message attribute is found for the specified type. + /// + public virtual MessageInfo GetMessageInfo(Type type) => + Types.TryGetValue(type, out var value) + ? value.attribute + : throw new PolyBusMessageNotFoundError(); /// - /// Attempts to get the message header associated with the specified type. + /// Gets the message header associated with the specified attribute. /// /// - /// If found, returns the message header; otherwise, returns null. + /// The message header associated with the specified attribute. /// - public virtual string? GetHeader(Type type) => - _types.TryGetValue(type, out var value) ? value.header : null; + /// + /// If no message header is found for the specified attribute. + /// + public virtual string GetHeaderByMessageInfo(MessageInfo messageInfo) => + Types.Values.Any(it => it.attribute.Equals(messageInfo)) + ? messageInfo.ToString(true) + : throw new PolyBusMessageNotFoundError(); /// /// Adds a message type to the collection. @@ -48,17 +44,20 @@ public class Messages /// /// The MessageAttribute associated with the message type. /// - /// - ///Type {messageType.FullName} does not have a MessageAttribute + /// + /// If the message type does not have a message info attribute defined. /// public virtual MessageInfo Add(Type messageType) { var attribute = messageType.GetCustomAttribute() - ?? throw new ArgumentException($"Type {messageType.FullName} does not have a MessageAttribute."); + ?? throw new PolyBusMessageNotFoundError(); var header = attribute.ToString(true); - _types.Add(messageType, (attribute, header)); - _map.TryAdd(header, messageType); + + if (!Types.TryAdd(messageType, (attribute, header))) + { + throw new PolyBusMessageNotFoundError(); + } return attribute; } @@ -67,11 +66,15 @@ public virtual MessageInfo Add(Type messageType) /// Attempts to get the message type associated with the specified attribute. /// /// - /// If found, returns the message type; otherwise, returns null. + /// The message type associated with the specified attribute. /// - public virtual Type? GetTypeByMessageInfo(MessageInfo messageInfo) => - _types + /// + /// If no message type is found for the specified message info attribute. + /// + public virtual Type GetTypeByMessageInfo(MessageInfo messageInfo) => + Types .Where(it => it.Value.attribute.Equals(messageInfo)) .Select(it => it.Key) - .FirstOrDefault(); + .FirstOrDefault() + ?? throw new PolyBusMessageNotFoundError(); } diff --git a/src/dotnet/PolyBus/Transport/Transactions/Messages/OutgoingMessage.cs b/src/dotnet/PolyBus/Transport/Transactions/Messages/OutgoingMessage.cs index 85a4658..433d820 100644 --- a/src/dotnet/PolyBus/Transport/Transactions/Messages/OutgoingMessage.cs +++ b/src/dotnet/PolyBus/Transport/Transactions/Messages/OutgoingMessage.cs @@ -3,25 +3,32 @@ namespace PolyBus.Transport.Transactions.Messages; [DebuggerStepThrough] -public class OutgoingMessage(IPolyBus bus, object message, string endpoint) : Message(bus) +public class OutgoingMessage(IPolyBus bus, object message, string? endpoint = null, MessageInfo? messageInfo = null) : Message(bus) { /// /// If the transport supports delayed messages, this is the time at which the message should be delivered. /// public virtual DateTime? DeliverAt { get; set; } + /// + /// The message info describing metadata about the message. + /// + public virtual MessageInfo MessageInfo { get; set; } = messageInfo ?? bus.Messages.GetMessageInfo(message.GetType()); + + /// + /// The type of the message. + /// public virtual Type MessageType { get; set; } = message.GetType(); /// - /// The serialized message body contents. + /// An optional location to explicitly send the message to. /// - public virtual string Body { get; set; } = message.ToString() ?? string.Empty; + public virtual string? Endpoint { get; set; } = endpoint; /// - /// If the message is a command then this is the endpoint the message is being sent to. - /// If the message is an event then this is the source endpoint the message is being sent from. + /// The serialized message body contents. /// - public virtual string Endpoint { get; set; } = endpoint; + public virtual string Body { get; set; } = string.Empty; /// /// The message object. diff --git a/src/dotnet/PolyBus/Transport/Transactions/Messages/PolyBusMessageNotFoundError.cs b/src/dotnet/PolyBus/Transport/Transactions/Messages/PolyBusMessageNotFoundError.cs new file mode 100644 index 0000000..8dfd7f0 --- /dev/null +++ b/src/dotnet/PolyBus/Transport/Transactions/Messages/PolyBusMessageNotFoundError.cs @@ -0,0 +1,7 @@ +namespace PolyBus.Transport.Transactions.Messages; + +/// +/// Is thrown when a requested type, attribute/decorator, or header was registered with the message system. +/// +public class PolyBusMessageNotFoundError() + : PolyBusError(2, "The requested type, attribute/decorator, or header was not found."); diff --git a/src/dotnet/PolyBus/Transport/Transactions/OutgoingTransactionFactory.cs b/src/dotnet/PolyBus/Transport/Transactions/OutgoingTransactionFactory.cs new file mode 100644 index 0000000..5a4db9c --- /dev/null +++ b/src/dotnet/PolyBus/Transport/Transactions/OutgoingTransactionFactory.cs @@ -0,0 +1,8 @@ +namespace PolyBus.Transport.Transactions; + +/// +/// A method for creating a new transaction for processing a request. +/// This should be used to integrate with external transaction systems to ensure message processing +/// is done within the context of a transaction. +/// +public delegate Task OutgoingTransactionFactory(PolyBusBuilder builder, IPolyBus bus); diff --git a/src/dotnet/PolyBus/Transport/Transactions/Transaction.cs b/src/dotnet/PolyBus/Transport/Transactions/Transaction.cs index 227d403..966dc19 100644 --- a/src/dotnet/PolyBus/Transport/Transactions/Transaction.cs +++ b/src/dotnet/PolyBus/Transport/Transactions/Transaction.cs @@ -21,15 +21,9 @@ public class Transaction(IPolyBus bus) /// public virtual IList OutgoingMessages { get; } = []; - public virtual OutgoingMessage AddOutgoingMessage(object message, string? endpoint = null) + public virtual OutgoingMessage Add(object message, string? endpoint = null) { - string GetEndpoint() - { - var messageInfo = bus.Messages.GetMessageInfo(message.GetType()) - ?? throw new ArgumentException($"Message type {message.GetType().FullName} is not registered."); - return messageInfo.Endpoint; - } - var outgoingMessage = new OutgoingMessage(bus, message, endpoint ?? GetEndpoint()); + var outgoingMessage = new OutgoingMessage(bus, message, endpoint); OutgoingMessages.Add(outgoingMessage); return outgoingMessage; } From d84b89d83153d83abe8c5e0a60725c8077d38fc3 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Tue, 25 Nov 2025 21:10:17 -0500 Subject: [PATCH 2/5] refactor: dotnet read me --- src/dotnet/README.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/dotnet/README.md b/src/dotnet/README.md index 2b83e39..33d6be7 100644 --- a/src/dotnet/README.md +++ b/src/dotnet/README.md @@ -7,27 +7,6 @@ A .NET implementation of the PolyBus messaging library, providing a unified inte - [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) or later - Any IDE that supports .NET development (Visual Studio, VS Code, JetBrains Rider) -## Project Structure - -``` -src/dotnet/ -├── PolyBus/ # Main library project -│ ├── PolyBus.csproj # Project file -│ ├── IPolyBus.cs # Main interface -│ ├── PolyBus.cs # Core implementation -│ ├── PolyBusBuilder.cs # Builder pattern implementation -│ ├── Headers.cs # Message headers -│ └── Transport/ # Transport implementations -│ ├── ITransport.cs # Transport interface -│ └── TransportFactory.cs -├── PloyBus.Tests/ # Test project -│ ├── PloyBus.Tests.csproj # Test project file -│ └── PolyBusTests.cs # Test implementations -├── Directory.Build.props # Common build properties -├── PolyBus.slnx # Solution file -└── lint.sh # Code quality script -``` - ## Quick Start ### Building the Project From c9fcf0ee245ba2ab7ae1f9eeb93c469883c38963 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Tue, 25 Nov 2025 23:49:52 -0500 Subject: [PATCH 3/5] refactor: python code --- src/python/README.md | 28 +- src/python/run_error_handler_tests.sh | 10 - src/python/src/headers.py | 8 +- src/python/src/i_poly_bus.py | 25 +- src/python/src/poly_bus.py | 95 ++- src/python/src/poly_bus_builder.py | 79 ++- src/python/src/poly_bus_error.py | 11 + src/python/src/transport/i_transport.py | 36 +- .../transport/in_memory/in_memory_endpoint.py | 131 ++++ .../in_memory/in_memory_message_broker.py | 170 ++++++ .../in_memory/in_memory_transport.py | 212 ------- .../transport/poly_bus_not_started_error.py | 14 + .../incoming_transaction_factory.py | 15 + .../message/handlers/error/error_handlers.py | 161 +++-- .../handlers/serializers/json_handlers.py | 78 +-- .../transaction/message/incoming_message.py | 24 +- .../transport/transaction/message/messages.py | 79 +-- .../transaction/message/outgoing_message.py | 41 +- .../poly_bus_message_not_found_error.py | 11 + .../outgoing_transaction_factory.py | 14 + .../src/transport/transaction/transaction.py | 14 +- .../transaction/transaction_factory.py | 22 - src/python/tests/test_poly_bus.py | 405 ------------- .../in_memory/test_in_memory_transport.py | 342 +++++++++-- .../handlers/error/test_error_handlers.py | 181 +++--- .../serializers/test_json_handlers.py | 566 +----------------- .../transaction/message/test_messages.py | 224 +++---- 27 files changed, 1259 insertions(+), 1737 deletions(-) delete mode 100755 src/python/run_error_handler_tests.sh create mode 100644 src/python/src/poly_bus_error.py create mode 100644 src/python/src/transport/in_memory/in_memory_endpoint.py create mode 100644 src/python/src/transport/in_memory/in_memory_message_broker.py delete mode 100644 src/python/src/transport/in_memory/in_memory_transport.py create mode 100644 src/python/src/transport/poly_bus_not_started_error.py create mode 100644 src/python/src/transport/transaction/incoming_transaction_factory.py create mode 100644 src/python/src/transport/transaction/message/poly_bus_message_not_found_error.py create mode 100644 src/python/src/transport/transaction/outgoing_transaction_factory.py delete mode 100644 src/python/src/transport/transaction/transaction_factory.py delete mode 100644 src/python/tests/test_poly_bus.py diff --git a/src/python/README.md b/src/python/README.md index bb03351..2c1ab41 100644 --- a/src/python/README.md +++ b/src/python/README.md @@ -8,30 +8,6 @@ A Python implementation of the PolyBus messaging library, providing a unified in - pip (Python package installer) - Any IDE that supports Python development (VS Code, PyCharm, etc.) -## Project Structure - -``` -src/python/ -├── src/ # Source code -│ ├── __init__.py # Package initialization -│ ├── i_poly_bus.py # Main interface -│ ├── poly_bus.py # Core implementation -│ ├── poly_bus_builder.py # Builder pattern implementation -│ ├── headers.py # Message headers -│ └── transport/ # Transport implementations -│ ├── __init__.py -│ └── i_transport.py # Transport interface -├── tests/ # Test package -│ ├── __init__.py -│ ├── test_poly_bus.py # Test implementations -│ └── transport/ # Transport tests -├── pyproject.toml # Project configuration and dependencies -├── requirements-dev.txt # Development dependencies -├── conftest.py # Pytest configuration -├── dev.sh # Development workflow script -└── setup.py # Legacy setup script -``` - ## Quick Start ### Setting Up Development Environment @@ -174,9 +150,9 @@ Pytest configuration includes: ./dev.sh help # Show all available commands # Direct pytest commands -python -m pytest # Run all tests +python -m pytest # Run all tests python -m pytest --cov-report=html # Generate HTML coverage report -python -m pytest tests/test_poly_bus.py # Run specific test file +python -m pytest tests/example.py # Run specific test file python -m pytest -x # Stop on first failure python -m pytest --lf # Run last failed tests only diff --git a/src/python/run_error_handler_tests.sh b/src/python/run_error_handler_tests.sh deleted file mode 100755 index 33bfcee..0000000 --- a/src/python/run_error_handler_tests.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -# Test runner script for PolyBus Python error handlers - -echo "Running Python error handler tests..." - -# Run tests with coverage and verbose output -/Users/cyscott/Library/Python/3.9/bin/pytest tests/transport/transaction/message/handlers/error/test_error_handlers.py -v --no-cov - -echo "Test run completed." \ No newline at end of file diff --git a/src/python/src/headers.py b/src/python/src/headers.py index 41a9c16..c58c5b4 100644 --- a/src/python/src/headers.py +++ b/src/python/src/headers.py @@ -8,8 +8,14 @@ class Headers: Common header names used in PolyBus. """ + #: The correlation id header name used for specifying the correlation identifier for tracking related messages. + CORRELATION_ID = "correlation-id" + #: The content type header name used for specifying the message content type (e.g., "application/json"). CONTENT_TYPE = "content-type" #: The message type header name used for specifying the type of the message. - MESSAGE_TYPE = "x-type" \ No newline at end of file + MESSAGE_TYPE = "x-type" + + #: The message id header name used for specifying the unique identifier of the message. + REQUEST_ID = "request-id" \ No newline at end of file diff --git a/src/python/src/i_poly_bus.py b/src/python/src/i_poly_bus.py index 3d44da4..61e3db6 100644 --- a/src/python/src/i_poly_bus.py +++ b/src/python/src/i_poly_bus.py @@ -1,7 +1,7 @@ """PolyBus interface for the Python implementation.""" from abc import ABC, abstractmethod -from typing import Dict, List, Optional, Any, TYPE_CHECKING +from typing import Dict, List, Any, TYPE_CHECKING if TYPE_CHECKING: from src.transport.i_transport import ITransport @@ -10,6 +10,8 @@ from src.transport.transaction.message.incoming_message import IncomingMessage from src.transport.transaction.message.messages import Messages from src.transport.transaction.transaction import Transaction + from src.transport.transaction.incoming_transaction import IncomingTransaction + from src.transport.transaction.outgoing_transaction import OutgoingTransaction class IPolyBus(ABC): @@ -29,13 +31,13 @@ def transport(self) -> 'ITransport': @property @abstractmethod - def incoming_handlers(self) -> 'List[IncomingHandler]': + def incoming_pipeline(self) -> 'List[IncomingHandler]': """Collection of handlers for processing incoming messages.""" pass @property @abstractmethod - def outgoing_handlers(self) -> 'List[OutgoingHandler]': + def outgoing_pipeline(self) -> 'List[OutgoingHandler]': """Collection of handlers for processing outgoing messages.""" pass @@ -52,14 +54,23 @@ def name(self) -> str: pass @abstractmethod - async def create_transaction(self, message: 'Optional[IncomingMessage]' = None) -> 'Transaction': - """Creates a new transaction, optionally based on an incoming message. + async def create_incoming_transaction(self, message: 'IncomingMessage') -> 'IncomingTransaction': + """Creates a new incoming transaction based on an incoming message. Args: - message: Optional incoming message to create the transaction from. + message: The incoming message to create the transaction from. Returns: - A new transaction instance. + A new incoming transaction instance. + """ + pass + + @abstractmethod + async def create_outgoing_transaction(self) -> 'OutgoingTransaction': + """Creates a new outgoing transaction. + + Returns: + A new outgoing transaction instance. """ pass diff --git a/src/python/src/poly_bus.py b/src/python/src/poly_bus.py index b9a569c..0a6df94 100644 --- a/src/python/src/poly_bus.py +++ b/src/python/src/poly_bus.py @@ -1,9 +1,15 @@ """PolyBus implementation for the Python version.""" -from typing import Dict, Any +from typing import Dict, Any, List from src.i_poly_bus import IPolyBus +from src.transport.i_transport import ITransport from src.transport.transaction.incoming_transaction import IncomingTransaction from src.transport.transaction.outgoing_transaction import OutgoingTransaction +from src.transport.transaction.message.incoming_message import IncomingMessage +from src.transport.transaction.message.messages import Messages +from src.transport.transaction.message.handlers.incoming_handler import IncomingHandler +from src.transport.transaction.message.handlers.outgoing_handler import OutgoingHandler +from src.transport.transaction.transaction import Transaction class PolyBus(IPolyBus): @@ -17,7 +23,7 @@ def __init__(self, builder): builder: The PolyBusBuilder containing the configuration """ self._builder = builder - self._transport = None + self._transport: ITransport = None # type: ignore @property def properties(self) -> Dict[str, Any]: @@ -25,29 +31,27 @@ def properties(self) -> Dict[str, Any]: return self._builder.properties @property - def transport(self): + def transport(self) -> ITransport: """The transport mechanism used by this bus instance.""" - if self._transport is None: - raise RuntimeError("Transport has not been initialized") return self._transport @transport.setter - def transport(self, value): + def transport(self, value: ITransport) -> None: """Set the transport mechanism used by this bus instance.""" self._transport = value @property - def incoming_handlers(self): + def incoming_pipeline(self) -> List[IncomingHandler]: """Collection of handlers for processing incoming messages.""" - return self._builder.incoming_handlers + return self._builder.incoming_pipeline @property - def outgoing_handlers(self): + def outgoing_pipeline(self) -> List[OutgoingHandler]: """Collection of handlers for processing outgoing messages.""" - return self._builder.outgoing_handlers + return self._builder.outgoing_pipeline @property - def messages(self): + def messages(self) -> Messages: """Collection of message types and their associated headers.""" return self._builder.messages @@ -56,40 +60,73 @@ def name(self) -> str: """The name of this bus instance.""" return self._builder.name - async def create_transaction(self, message=None): - """Creates a new transaction, optionally based on an incoming message. + @property + def incoming_handlers(self) -> List[IncomingHandler]: + """Backwards-compatible alias for incoming_pipeline.""" + return self.incoming_pipeline + + @property + def outgoing_handlers(self) -> List[OutgoingHandler]: + """Backwards-compatible alias for outgoing_pipeline.""" + return self.outgoing_pipeline + + async def create_transaction(self, message: IncomingMessage = None): + """Backwards-compatible method to create transactions. Args: - message: Optional incoming message to create the transaction from. + message: Optional incoming message. If provided, creates an incoming transaction. + If not provided, creates an outgoing transaction. + + Returns: + Either an IncomingTransaction or OutgoingTransaction depending on whether message is provided. + """ + if message is not None: + return await self.create_incoming_transaction(message) + else: + return await self.create_outgoing_transaction() + + async def create_incoming_transaction(self, message: IncomingMessage) -> IncomingTransaction: + """Creates a new incoming transaction based on an incoming message. + + Args: + message: The incoming message to create the transaction from. + + Returns: + A new incoming transaction instance. + """ + return await self._builder.incoming_transaction_factory(self._builder, self, message) + + async def create_outgoing_transaction(self) -> OutgoingTransaction: + """Creates a new outgoing transaction. Returns: - A new transaction instance. + A new outgoing transaction instance. """ - return await self._builder.transaction_factory(self._builder, self, message) + return await self._builder.outgoing_transaction_factory(self._builder, self) - async def send(self, transaction) -> None: + async def send(self, transaction: Transaction) -> None: """Sends messages associated with the given transaction to the transport. Args: transaction: The transaction containing messages to send. """ - async def final_step(): + async def final_step() -> None: """Final step that actually sends to transport.""" - await self.transport.send(transaction) + await self.transport.handle(transaction) step = final_step # Build handler chain based on transaction type if isinstance(transaction, IncomingTransaction): # Process incoming handlers in reverse order - handlers = self.incoming_handlers - for i in range(len(handlers) - 1, -1, -1): - handler = handlers[i] + handlers = transaction.bus.incoming_pipeline + for index in range(len(handlers) - 1, -1, -1): + handler = handlers[index] next_step = step # Fix closure issue by using default parameters - def create_step(h=handler, next_fn=next_step): - async def handler_step(): + def create_step(h: IncomingHandler = handler, next_fn = next_step) -> Any: + async def handler_step() -> None: await h(transaction, next_fn) return handler_step @@ -97,14 +134,14 @@ async def handler_step(): elif isinstance(transaction, OutgoingTransaction): # Process outgoing handlers in reverse order - handlers = self.outgoing_handlers - for i in range(len(handlers) - 1, -1, -1): - handler = handlers[i] + handlers = transaction.bus.outgoing_pipeline + for index in range(len(handlers) - 1, -1, -1): + handler = handlers[index] next_step = step # Fix closure issue by using default parameters - def create_step(h=handler, next_fn=next_step): - async def handler_step(): + def create_step(h: OutgoingHandler = handler, next_fn = next_step) -> Any: + async def handler_step() -> None: await h(transaction, next_fn) return handler_step diff --git a/src/python/src/poly_bus_builder.py b/src/python/src/poly_bus_builder.py index aa2ab42..48e475a 100644 --- a/src/python/src/poly_bus_builder.py +++ b/src/python/src/poly_bus_builder.py @@ -9,23 +9,33 @@ from src.poly_bus import PolyBus -async def _default_transaction_factory(builder: 'PolyBusBuilder', bus, message: Optional = None): +async def _default_incoming_transaction_factory(builder: 'PolyBusBuilder', bus, message): """ - Default transaction factory implementation. + Default incoming transaction factory implementation. Args: builder: The PolyBus builder instance (not used in default implementation) bus: The PolyBus instance - message: The incoming message to process, if any + message: The incoming message to process Returns: - A new transaction instance + A new incoming transaction instance """ + return IncomingTransaction(bus, message) + + +async def _default_outgoing_transaction_factory(builder: 'PolyBusBuilder', bus): + """ + Default outgoing transaction factory implementation. - if message is not None: - return IncomingTransaction(bus, message) - else: - return OutgoingTransaction(bus) + Args: + builder: The PolyBus builder instance (not used in default implementation) + bus: The PolyBus instance + + Returns: + A new outgoing transaction instance + """ + return OutgoingTransaction(bus) async def _default_transport_factory(builder: 'PolyBusBuilder', bus: 'PolyBus'): @@ -37,11 +47,11 @@ async def _default_transport_factory(builder: 'PolyBusBuilder', bus: 'PolyBus'): bus: The PolyBus instance Returns: - An InMemoryTransport endpoint for the bus + An InMemoryMessageBroker endpoint for the bus """ - from .transport.in_memory.in_memory_transport import InMemoryTransport + from .transport.in_memory.in_memory_message_broker import InMemoryMessageBroker - transport = InMemoryTransport() + transport = InMemoryMessageBroker() return transport.add_endpoint(builder, bus) @@ -50,10 +60,11 @@ class PolyBusBuilder: def __init__(self): """Initialize a new PolyBusBuilder with default settings.""" - # The transaction factory will be used to create transactions for message handling. - # Transactions are used to ensure that a group of message related to a single request - # are sent to the transport in a single atomic operation. - self.transaction_factory = _default_transaction_factory + # The incoming transaction factory will be used to create incoming transactions for handling messages. + self.incoming_transaction_factory = _default_incoming_transaction_factory + + # The outgoing transaction factory will be used to create outgoing transactions for sending messages. + self.outgoing_transaction_factory = _default_outgoing_transaction_factory # The transport factory will be used to create the transport for the PolyBus instance. # The transport is responsible for sending and receiving messages. @@ -63,16 +74,48 @@ def __init__(self): self.properties: Dict[str, Any] = {} # Collection of handlers for processing incoming messages - self.incoming_handlers = [] + self.incoming_pipeline = [] # Collection of handlers for processing outgoing messages - self.outgoing_handlers = [] + self.outgoing_pipeline = [] # Collection of message types and their associated headers self.messages = Messages() # The name of the bus instance - self.name: str = "PolyBusInstance" + self.name: str = "polybus" + + @property + def incoming_handlers(self): + """Backwards-compatible alias for incoming_pipeline.""" + return self.incoming_pipeline + + @property + def outgoing_handlers(self): + """Backwards-compatible alias for outgoing_pipeline.""" + return self.outgoing_pipeline + + @property + def transaction_factory(self): + """Backwards-compatible combined transaction factory.""" + async def combined_factory(builder, bus, message=None): + if message is not None: + return await self.incoming_transaction_factory(builder, bus, message) + else: + return await self.outgoing_transaction_factory(builder, bus) + return combined_factory + + @transaction_factory.setter + def transaction_factory(self, value): + """Set both incoming and outgoing transaction factories from a combined factory.""" + async def incoming_wrapper(builder, bus, message): + return await value(builder, bus, message) + + async def outgoing_wrapper(builder, bus): + return await value(builder, bus, None) + + self.incoming_transaction_factory = incoming_wrapper + self.outgoing_transaction_factory = outgoing_wrapper async def build(self) -> 'PolyBus': """ diff --git a/src/python/src/poly_bus_error.py b/src/python/src/poly_bus_error.py new file mode 100644 index 0000000..abc3a21 --- /dev/null +++ b/src/python/src/poly_bus_error.py @@ -0,0 +1,11 @@ +""" +Base exception class for PolyBus errors. +""" + + +class PolyBusError(Exception): + """Base exception for PolyBus errors.""" + + def __init__(self, error_code: int, message: str): + super().__init__(message) + self.error_code = error_code diff --git a/src/python/src/transport/i_transport.py b/src/python/src/transport/i_transport.py index b9564cc..1331bf1 100644 --- a/src/python/src/transport/i_transport.py +++ b/src/python/src/transport/i_transport.py @@ -10,43 +10,49 @@ class ITransport(ABC): @property @abstractmethod - def supports_delayed_messages(self) -> bool: - """Whether this transport supports delayed message delivery.""" + def dead_letter_endpoint(self) -> str: + """Where messages that cannot be delivered are sent.""" pass - @property @abstractmethod - def supports_command_messages(self) -> bool: - """Whether this transport supports command messages.""" + async def handle(self, transaction: 'Transaction') -> None: + """Sends messages associated with the given transaction to the transport. + + Args: + transaction: The transaction containing messages to send. + """ pass @property @abstractmethod - def supports_subscriptions(self) -> bool: - """Whether this transport supports message subscriptions.""" + def supports_delayed_commands(self) -> bool: + """If the transport supports sending delayed commands, this will be true.""" pass + @property @abstractmethod - async def send(self, transaction: 'Transaction') -> None: - """Sends messages associated with the given transaction to the transport. - - Args: - transaction: The transaction containing messages to send. - """ + def supports_command_messages(self) -> bool: + """If the transport supports sending command messages, this will be true.""" pass @abstractmethod async def subscribe(self, message_info: 'MessageInfo') -> None: - """Subscribes to messages so that the transport can start receiving them. + """Subscribes to a messages so that the transport can start receiving them. Args: message_info: Information about the message type to subscribe to. """ pass + @property + @abstractmethod + def supports_subscriptions(self) -> bool: + """If the transport supports event message subscriptions, this will be true.""" + pass + @abstractmethod async def start(self) -> None: - """Enables the transport to start processing messages.""" + """Starts the transport to start processing messages.""" pass @abstractmethod diff --git a/src/python/src/transport/in_memory/in_memory_endpoint.py b/src/python/src/transport/in_memory/in_memory_endpoint.py new file mode 100644 index 0000000..74a7c78 --- /dev/null +++ b/src/python/src/transport/in_memory/in_memory_endpoint.py @@ -0,0 +1,131 @@ + +""" +An implementation of an in-memory transport endpoint. +""" + +from typing import Callable, Optional, Dict +from src.i_poly_bus import IPolyBus +from src.transport.i_transport import ITransport +from src.transport.transaction.transaction import Transaction +from src.transport.transaction.message.incoming_message import IncomingMessage +from src.transport.transaction.message.message_info import MessageInfo +from src.transport.poly_bus_not_started_error import PolyBusNotStartedError + + +class InMemoryEndpoint(ITransport): + """An implementation of an in-memory transport endpoint.""" + + def __init__(self, broker: 'InMemoryMessageBroker', bus: IPolyBus): + """Initialize the endpoint. + + Args: + broker: The message broker for routing messages + bus: The associated PolyBus instance + """ + self._broker = broker + self._bus = bus + self._dead_letter_handler: Optional[Callable[[IncomingMessage], None]] = None + self._active = False + self._subscriptions: Dict[str, bool] = {} + + @property + def bus(self) -> IPolyBus: + """The associated PolyBus instance.""" + return self._bus + + @property + def dead_letter_handler(self) -> Optional[Callable[[IncomingMessage], None]]: + """Handler for dead letter messages.""" + return self._dead_letter_handler + + @dead_letter_handler.setter + def dead_letter_handler(self, value: Optional[Callable[[IncomingMessage], None]]) -> None: + """Set the dead letter handler.""" + self._dead_letter_handler = value + + @property + def active(self) -> bool: + """Whether the endpoint is currently active.""" + return self._active + + @property + def dead_letter_endpoint(self) -> str: + """The dead letter endpoint name.""" + return f"{self._bus.name}.dead.letters" + + async def handle_message(self, message: IncomingMessage, is_dead_letter: bool) -> None: + """If active, handles an incoming message by creating a transaction and executing the handlers for the bus. + + Args: + message: The incoming message to handle + is_dead_letter: Whether this is a dead letter message + """ + if self._active: + if is_dead_letter: + if self._dead_letter_handler: + self._dead_letter_handler(message) + else: + transaction = await self._bus.create_incoming_transaction(message) + await self._bus.send(transaction) + + async def handle(self, transaction: Transaction) -> None: + """Sends messages associated with the given transaction to the transport. + + Args: + transaction: The transaction containing messages to send + + Raises: + PolyBusNotStartedError: If the endpoint is not active + """ + if not self._active: + raise PolyBusNotStartedError() + + self._broker.send(transaction) + + @property + def supports_delayed_commands(self) -> bool: + """If the transport supports sending delayed commands, this will be true.""" + return True + + @property + def supports_command_messages(self) -> bool: + """If the transport supports sending command messages, this will be true.""" + return True + + async def subscribe(self, message_info: MessageInfo) -> None: + """Subscribes to a messages so that the transport can start receiving them. + + Args: + message_info: Information about the message type to subscribe to + + Raises: + PolyBusNotStartedError: If the endpoint is not active + """ + if not self._active: + raise PolyBusNotStartedError() + + self._subscriptions[message_info.to_string(include_version=False)] = True + + def is_subscribed(self, message_info: MessageInfo) -> bool: + """Check if subscribed to a message type. + + Args: + message_info: The message type to check + + Returns: + True if subscribed to this message type + """ + return message_info.to_string(include_version=False) in self._subscriptions + + @property + def supports_subscriptions(self) -> bool: + """If the transport supports event message subscriptions, this will be true.""" + return True + + async def start(self) -> None: + """Starts the transport to start processing messages.""" + self._active = True + + async def stop(self) -> None: + """Stops the transport from processing messages.""" + self._active = False \ No newline at end of file diff --git a/src/python/src/transport/in_memory/in_memory_message_broker.py b/src/python/src/transport/in_memory/in_memory_message_broker.py new file mode 100644 index 0000000..d435126 --- /dev/null +++ b/src/python/src/transport/in_memory/in_memory_message_broker.py @@ -0,0 +1,170 @@ + +""" +A message broker that uses in-memory transport for message passing. +""" + +import asyncio +import logging +import threading +from datetime import datetime, timezone +from typing import Dict +from uuid import uuid4 +from src.i_poly_bus import IPolyBus +from src.poly_bus_builder import PolyBusBuilder +from src.transport.i_transport import ITransport +from src.transport.in_memory.in_memory_endpoint import InMemoryEndpoint +from src.transport.transaction.transaction import Transaction +from src.transport.transaction.message.incoming_message import IncomingMessage + + +class InMemoryMessageBroker: + """A message broker that uses in-memory transport for message passing.""" + + def __init__(self) -> None: + """Initialize the message broker.""" + self._endpoints: Dict[str, InMemoryEndpoint] = {} + self._cancellation_token = None + self._log = logging.getLogger(__name__) + self._tasks: Dict[str, asyncio.Task] = {} + self._tasks_lock = threading.Lock() + + @property + def endpoints(self) -> Dict[str, InMemoryEndpoint]: + """The collection of in-memory endpoints managed by this broker.""" + return self._endpoints + + @property + def log(self) -> logging.Logger: + """The logger for the InMemoryMessageBroker.""" + return self._log + + @log.setter + def log(self, value: logging.Logger) -> None: + """Set the logger for the InMemoryMessageBroker.""" + self._log = value + + async def add_endpoint(self, builder: PolyBusBuilder, bus: IPolyBus) -> ITransport: + """The ITransport factory method. + + Args: + builder: The builder used to configure the bus + bus: The bus instance to create an endpoint for + + Returns: + The transport endpoint + """ + endpoint = InMemoryEndpoint(self, bus) + self._endpoints[bus.name] = endpoint + return endpoint + + def send(self, transaction: Transaction) -> None: + """Processes the transaction and distributes outgoing messages to the appropriate endpoints. + + Args: + transaction: The transaction containing outgoing messages + """ + if not transaction.outgoing_messages: + return + + asyncio.create_task(self._send_async(transaction)) + + async def _send_async(self, transaction: Transaction) -> None: + """Async implementation of send processing. + + Args: + transaction: The transaction containing outgoing messages + """ + try: + await asyncio.sleep(0) # convert to async context + + task_id = str(uuid4()) + tasks = [] + now = datetime.now(timezone.utc) + + for message in transaction.outgoing_messages: + for endpoint in self._endpoints.values(): + is_dead_letter = endpoint.dead_letter_endpoint == message.endpoint + if (is_dead_letter + or endpoint.bus.name == message.endpoint + or (message.endpoint is None + and (message.message_info.endpoint == endpoint.bus.name + or endpoint.is_subscribed(message.message_info)))): + + incoming_message = IncomingMessage( + endpoint.bus, + message.body, + message.message_info + ) + incoming_message.headers = dict(message.headers) + + if message.deliver_at is not None: + wait = (message.deliver_at - now).total_seconds() + if wait > 0: + # Schedule delayed send + schedule_task_id = str(uuid4()) + task = asyncio.create_task( + self._delayed_send(schedule_task_id, endpoint, incoming_message, wait, is_dead_letter) + ) + with self._tasks_lock: + self._tasks[schedule_task_id] = task + continue + + task = endpoint.handle_message(incoming_message, is_dead_letter) + tasks.append(task) + + if tasks: + task = asyncio.gather(*tasks) + with self._tasks_lock: + self._tasks[task_id] = task + except Exception as error: + self._log.error(str(error), exc_info=True) + finally: + # Clean up any completed delayed tasks + with self._tasks_lock: + if task_id in self._tasks: + del self._tasks[task_id] + + async def _delayed_send( + self, + task_id: str, + endpoint: InMemoryEndpoint, + message: IncomingMessage, + delay: float, + is_dead_letter: bool + ) -> None: + """Send a message after a delay. + + Args: + endpoint: The endpoint to send to + message: The message to send + delay: Delay in seconds + is_dead_letter: Whether this is a dead letter message + """ + try: + await asyncio.sleep(delay) + await endpoint.handle_message(message, is_dead_letter) + except asyncio.CancelledError: + # Ignore cancellation + pass + except Exception as error: + self._log.error(str(error), exc_info=True) + finally: + # Remove task from tracking dictionary + with self._tasks_lock: + if task_id in self._tasks: + del self._tasks[task_id] + + async def stop(self) -> None: + """Stop the transport and wait for all pending operations.""" + for endpoint in self._endpoints.values(): + await endpoint.stop() + + with self._tasks_lock: + tasks_to_cancel = list(self._tasks.values()) + + for task in tasks_to_cancel: + task.cancel() + + # Wait for all tasks to complete cancellation + if tasks_to_cancel: + await asyncio.gather(*tasks_to_cancel, return_exceptions=True) \ No newline at end of file diff --git a/src/python/src/transport/in_memory/in_memory_transport.py b/src/python/src/transport/in_memory/in_memory_transport.py deleted file mode 100644 index 1c10bc4..0000000 --- a/src/python/src/transport/in_memory/in_memory_transport.py +++ /dev/null @@ -1,212 +0,0 @@ -"""In-memory transport implementation for PolyBus Python.""" - -import asyncio -import datetime -import logging -from datetime import datetime, timedelta, timezone -import threading -from typing import Dict, List, Type -from uuid import uuid1 -from src.transport.i_transport import ITransport -from src.transport.transaction.transaction import Transaction -from src.transport.transaction.message.incoming_message import IncomingMessage -from src.transport.transaction.message.outgoing_message import OutgoingMessage -from src.transport.transaction.message.message_info import MessageInfo - - -class InMemoryTransport: - """In-memory transport that can handle multiple bus endpoints.""" - - def __init__(self): - self._endpoints: Dict[str, 'Endpoint'] = {} - self._active = False - self._cancellation_token = None - self._tasks: Dict[str, asyncio.Task] = {} - self._tasks_lock = threading.Lock() - self.use_subscriptions = False - - def add_endpoint(self, builder, bus) -> ITransport: - """Add a new endpoint for the given bus. - - Args: - builder: PolyBusBuilder instance (not used in current implementation) - bus: IPolyBus instance - - Returns: - ITransport endpoint for the bus - """ - endpoint = Endpoint(self, bus) - self._endpoints[bus.name] = endpoint - return endpoint - - async def send(self, transaction: Transaction) -> None: - """Send messages from a transaction to all endpoints. - - Args: - transaction: Transaction containing outgoing messages - - Raises: - RuntimeError: If transport is not active - """ - if not self._active: - raise RuntimeError("Transport is not active.") - - if not transaction.outgoing_messages: - return - - task_id = uuid1() - tasks = [] - now = datetime.now(timezone.utc) - - try: - for message in transaction.outgoing_messages: - if message.deliver_at is not None: - wait_time = message.deliver_at - now - if wait_time.total_seconds() > 0: - # Schedule delayed send - schedule_task_id = uuid1() - task = asyncio.create_task(self._delayed_send_async(schedule_task_id, message, wait_time)) - with self._tasks_lock: - self._tasks[schedule_task_id] = task - continue - - # Send to all endpoints immediately - for endpoint in self._endpoints.values(): - task = endpoint.handle(message) - tasks.append(task) - - if tasks: - task = asyncio.gather(*tasks) - with self._tasks_lock: - self._tasks[task_id] = task - finally: - # Clean up any completed delayed tasks - with self._tasks_lock: - if task_id in self._tasks: - del self._tasks[task_id] - - async def _delayed_send_async(self, task_id: str, message: OutgoingMessage, delay: timedelta) -> None: - """Send a message after the specified delay. - - Args: - message: The message to send - delay: How long to wait before sending - """ - try: - await asyncio.sleep(delay.total_seconds()) - transaction = await message.bus.create_transaction() - message.deliver_at = None - transaction.outgoing_messages.append(message) - await self.send(transaction) - except asyncio.CancelledError: - # Ignore cancellation - pass - except Exception as error: - # Try to find a logger in the message state - logger = None - for value in message.state.values(): - if isinstance(value, logging.Logger): - logger = value - break - - if logger: - logger.error(f"Error in delayed send: {error}", exc_info=True) - finally: - # Remove task from tracking dictionary - with self._transport._tasks_lock: - if task_id in self._transport._tasks: - del self._transport._tasks[task_id] - - async def start(self) -> None: - """Start the transport.""" - self._active = True - # Don't set cancellation token to current task - only for transport-internal tasks - self._cancellation_token = None - - async def stop(self) -> None: - """Stop the transport and wait for all pending operations.""" - self._active = False - - with self._tasks_lock: - tasks_to_cancel = list(self._tasks.values()) - - for task in tasks_to_cancel: - task.cancel() - - -class Endpoint(ITransport): - """Transport endpoint for a specific bus instance.""" - - def __init__(self, transport: InMemoryTransport, bus): - self._transport = transport - self._bus = bus - self._subscriptions: List[Type] = [] - - async def handle(self, message: OutgoingMessage) -> None: - """Handle an incoming message from another endpoint. - - Args: - message: The outgoing message from another endpoint - """ - if not self._transport.use_subscriptions or message.message_type in self._subscriptions: - incoming_message = IncomingMessage(self._bus, message.body) - incoming_message.headers = message.headers - - try: - transaction = await self._bus.create_transaction(incoming_message) - await transaction.commit() - except Exception as error: - # Try to find a logger in the message state - logger = None - for value in incoming_message.state.values(): - if isinstance(value, logging.Logger): - logger = value - break - - if logger: - logger.error(f"Error handling message: {error}", exc_info=True) - - async def subscribe(self, message_info: MessageInfo) -> None: - """Subscribe to messages of a specific type. - - Args: - message_info: Information about the message type to subscribe to - - Raises: - ValueError: If message type is not registered - """ - message_type = self._bus.messages.get_type_by_message_info(message_info) - if message_type is None: - raise ValueError(f"Message type for attribute {message_info} is not registered.") - self._subscriptions.append(message_type) - - @property - def supports_command_messages(self) -> bool: - """Whether this transport supports command messages.""" - return True - - @property - def supports_delayed_messages(self) -> bool: - """Whether this transport supports delayed message delivery.""" - return True - - @property - def supports_subscriptions(self) -> bool: - """Whether this transport supports message subscriptions.""" - return True - - async def send(self, transaction: Transaction) -> None: - """Send messages through the transport. - - Args: - transaction: Transaction containing messages to send - """ - await self._transport.send(transaction) - - async def start(self) -> None: - """Start the transport endpoint.""" - await self._transport.start() - - async def stop(self) -> None: - """Stop the transport endpoint.""" - await self._transport.stop() \ No newline at end of file diff --git a/src/python/src/transport/poly_bus_not_started_error.py b/src/python/src/transport/poly_bus_not_started_error.py new file mode 100644 index 0000000..af0be4d --- /dev/null +++ b/src/python/src/transport/poly_bus_not_started_error.py @@ -0,0 +1,14 @@ +""" +PolyBus error indicating that the bus has not been started. +""" + +from ..poly_bus_error import PolyBusError + + +class PolyBusNotStartedError(PolyBusError): + """A PolyBus error indicating that the bus has not been started.""" + + def __init__(self) -> None: + super().__init__( + 1, "PolyBus has not been started. Please call IPolyBus.start() before using the bus." + ) diff --git a/src/python/src/transport/transaction/incoming_transaction_factory.py b/src/python/src/transport/transaction/incoming_transaction_factory.py new file mode 100644 index 0000000..0580316 --- /dev/null +++ b/src/python/src/transport/transaction/incoming_transaction_factory.py @@ -0,0 +1,15 @@ +"""Incoming transaction factory for creating incoming transactions in the PolyBus Python implementation.""" + +from typing import Callable, Awaitable +from src.transport.transaction.incoming_transaction import IncomingTransaction +from src.transport.transaction.message.incoming_message import IncomingMessage + +IncomingTransactionFactory = Callable[ + ['PolyBusBuilder', 'IPolyBus', 'IncomingMessage'], + Awaitable['IncomingTransaction'] +] +""" +A method for creating a new transaction for processing a request. +This should be used to integrate with external transaction systems to ensure message processing +is done within the context of a transaction. +""" diff --git a/src/python/src/transport/transaction/message/handlers/error/error_handlers.py b/src/python/src/transport/transaction/message/handlers/error/error_handlers.py index 4817fc7..efcec2a 100644 --- a/src/python/src/transport/transaction/message/handlers/error/error_handlers.py +++ b/src/python/src/transport/transaction/message/handlers/error/error_handlers.py @@ -1,50 +1,105 @@ """Error handling with retry logic for PolyBus Python implementation.""" +import logging +import traceback from datetime import datetime, timedelta, timezone -from typing import Callable, Awaitable, Optional +from typing import Callable, Awaitable from src.transport.transaction.incoming_transaction import IncomingTransaction +from src.transport.transaction.message.outgoing_message import OutgoingMessage class ErrorHandler: - """Provides error handling and retry logic for message processing.""" - - ERROR_MESSAGE_HEADER = "X-Error-Message" - ERROR_STACK_TRACE_HEADER = "X-Error-Stack-Trace" - RETRY_COUNT_HEADER = "X-Retry-Count" - - def __init__( - self, - delay: int = 30, - delayed_retry_count: int = 3, - immediate_retry_count: int = 3, - dead_letter_endpoint: Optional[str] = None - ): - """Initialize the error handler. + """A handler for processing message errors with retry logic.""" + + def __init__(self): + """Initialize the error handler with default values.""" + self._log: logging.Logger = logging.getLogger(__name__) + self._delay_increment: int = 30 + self._delayed_retry_count: int = 3 + self._immediate_retry_count: int = 3 + self._error_message_header: str = "x-error-message" + self._error_stack_trace_header: str = "x-error-stack-trace" + self._retry_count_header: str = "x-retry-count" + + @property + def log(self) -> logging.Logger: + """The logger instance to use for logging.""" + return self._log + + @log.setter + def log(self, value: logging.Logger) -> None: + self._log = value + + @property + def delay_increment(self) -> int: + """The delay increment in seconds for each delayed retry attempt. - Args: - delay: Base delay in seconds between delayed retries - delayed_retry_count: Number of delayed retry attempts - immediate_retry_count: Number of immediate retry attempts - dead_letter_endpoint: Optional endpoint for dead letter messages + The delay is calculated as: delay attempt number * delay increment. """ - self.delay = delay - self.delayed_retry_count = delayed_retry_count - self.immediate_retry_count = immediate_retry_count - self.dead_letter_endpoint = dead_letter_endpoint + return self._delay_increment + + @delay_increment.setter + def delay_increment(self, value: int) -> None: + self._delay_increment = value + + @property + def delayed_retry_count(self) -> int: + """How many delayed retry attempts to make before sending to the dead-letter queue.""" + return self._delayed_retry_count + + @delayed_retry_count.setter + def delayed_retry_count(self, value: int) -> None: + self._delayed_retry_count = value + + @property + def immediate_retry_count(self) -> int: + """How many immediate retry attempts to make before applying delayed retries.""" + return self._immediate_retry_count + + @immediate_retry_count.setter + def immediate_retry_count(self, value: int) -> None: + self._immediate_retry_count = value + + @property + def error_message_header(self) -> str: + """The header key for storing error messages in dead-lettered messages.""" + return self._error_message_header + + @error_message_header.setter + def error_message_header(self, value: str) -> None: + self._error_message_header = value + + @property + def error_stack_trace_header(self) -> str: + """The header key for storing error stack traces in dead-lettered messages.""" + return self._error_stack_trace_header + + @error_stack_trace_header.setter + def error_stack_trace_header(self, value: str) -> None: + self._error_stack_trace_header = value + + @property + def retry_count_header(self) -> str: + """The header key for storing the delayed retry count.""" + return self._retry_count_header + + @retry_count_header.setter + def retry_count_header(self, value: str) -> None: + self._retry_count_header = value async def retrier( self, transaction: IncomingTransaction, next_handler: Callable[[], Awaitable[None]] ) -> None: - """Handle message processing with retry logic. + """Retries the processing of a message according to the configured retry logic. Args: transaction: The incoming transaction to process next_handler: The next handler in the pipeline """ # Get the current delayed retry attempt count - retry_header = transaction.incoming_message.headers.get(self.RETRY_COUNT_HEADER, "0") + retry_header = transaction.incoming_message.headers.get(self.retry_count_header, "0") try: delayed_attempt = int(retry_header) except ValueError: @@ -59,6 +114,14 @@ async def retrier( await next_handler() break # Success, exit retry loop except Exception as error: + self.log.error( + "Error processing message %s (immediate attempts: %d, delayed attempts: %d): %s", + transaction.incoming_message.message_info, + immediate_attempt, + delayed_attempt, + str(error) + ) + # Clear any outgoing messages from failed attempt transaction.outgoing_messages.clear() @@ -67,31 +130,37 @@ async def retrier( continue # Check if we can do delayed retries - if delayed_attempt < delayed_retry_count: + if ( + transaction.incoming_message.bus.transport.supports_delayed_commands + and delayed_attempt < delayed_retry_count + ): # Re-queue the message with a delay delayed_attempt += 1 - delayed_message = transaction.add_outgoing_message( + delayed_message = OutgoingMessage( + transaction.bus, transaction.incoming_message.message, - transaction.bus.name + transaction.bus.name, + transaction.incoming_message.message_info ) delayed_message.deliver_at = self.get_next_retry_time(delayed_attempt) - delayed_message.headers[self.RETRY_COUNT_HEADER] = str(delayed_attempt) + delayed_message.headers = transaction.incoming_message.headers.copy() + delayed_message.headers[self.retry_count_header] = str(delayed_attempt) + transaction.outgoing_messages.append(delayed_message) continue # All retries exhausted, send to dead letter queue - dead_letter_endpoint = ( - self.dead_letter_endpoint or f"{transaction.bus.name}.Errors" - ) - dead_letter_message = transaction.add_outgoing_message( - transaction.incoming_message.message, - dead_letter_endpoint - ) - dead_letter_message.headers[self.ERROR_MESSAGE_HEADER] = str(error) - dead_letter_message.headers[self.ERROR_STACK_TRACE_HEADER] = ( - self._get_stack_trace() + dead_letter_message = OutgoingMessage( + transaction.bus, + transaction.incoming_message.message, + transaction.bus.transport.dead_letter_endpoint, + transaction.incoming_message.message_info ) + dead_letter_message.headers = transaction.incoming_message.headers.copy() + dead_letter_message.headers[self.error_message_header] = str(error) + dead_letter_message.headers[self.error_stack_trace_header] = traceback.format_exc() + transaction.outgoing_messages.append(dead_letter_message) def get_next_retry_time(self, attempt: int) -> datetime: """Calculate the next retry time based on attempt number. @@ -102,14 +171,4 @@ def get_next_retry_time(self, attempt: int) -> datetime: Returns: The datetime when the next retry should occur """ - return datetime.now(timezone.utc) + timedelta(seconds=attempt * self.delay) - - @staticmethod - def _get_stack_trace() -> str: - """Extract stack trace from an exception. - - Returns: - The stack trace as a string - """ - import traceback - return traceback.format_exc() \ No newline at end of file + return datetime.now(timezone.utc) + timedelta(seconds=attempt * self.delay_increment) \ No newline at end of file diff --git a/src/python/src/transport/transaction/message/handlers/serializers/json_handlers.py b/src/python/src/transport/transaction/message/handlers/serializers/json_handlers.py index 293db4f..72aacd6 100644 --- a/src/python/src/transport/transaction/message/handlers/serializers/json_handlers.py +++ b/src/python/src/transport/transaction/message/handlers/serializers/json_handlers.py @@ -8,65 +8,39 @@ class JsonHandlers: - """Provides JSON serialization and deserialization handlers for message processing.""" + """Handlers for serializing and deserializing messages as JSON.""" def __init__( self, json_options: Optional[dict] = None, content_type: str = "application/json", - throw_on_missing_type: bool = True, - throw_on_invalid_type: bool = True + header: str = Headers.CONTENT_TYPE ): """Initialize the JSON handlers. Args: json_options: Optional dictionary of options to pass to json.dumps/loads - content_type: The content type header value to set for serialized messages - throw_on_missing_type: Whether to throw an exception when type header is missing/invalid - throw_on_invalid_type: Whether to throw an exception when message type is not registered + content_type: The content type to set on outgoing messages + header: The header key to use for the content type """ self.json_options = json_options or {} self.content_type = content_type - self.throw_on_missing_type = throw_on_missing_type - self.throw_on_invalid_type = throw_on_invalid_type + self.header = header async def deserializer( self, transaction: IncomingTransaction, next_handler: Callable[[], Awaitable[None]] ) -> None: - """Deserialize incoming message body from JSON. + """Deserializes incoming messages from JSON. Args: transaction: The incoming transaction containing the message to deserialize next_handler: The next handler in the pipeline - - Raises: - InvalidOperationError: When type header is missing and throw_on_missing_type is True """ - message = transaction.incoming_message + incoming_message = transaction.incoming_message - # Try to get the message type from the headers - message_type_header = message.headers.get(Headers.MESSAGE_TYPE) - message_type = None - - if message_type_header: - message_type = message.bus.messages.get_type_by_header(message_type_header) - - if message_type is None and self.throw_on_missing_type: - raise InvalidOperationError( - "The type header is missing, invalid, or if the type cannot be found." - ) - - # Deserialize the message - if message_type is None: - # No type available, parse as generic JSON - message.message = json.loads(message.body, **self.json_options) - else: - # We have a type, but for Python we'll still parse as JSON and let - # the application handle the type conversion - message.message = json.loads(message.body, **self.json_options) - message.message_type = message_type + incoming_message.message = json.loads(incoming_message.body, **self.json_options) await next_handler() @@ -75,31 +49,25 @@ async def serializer( transaction: OutgoingTransaction, next_handler: Callable[[], Awaitable[None]] ) -> None: - """Serialize outgoing message objects to JSON. + """Serializes outgoing messages to JSON. Args: transaction: The outgoing transaction containing messages to serialize next_handler: The next handler in the pipeline - - Raises: - InvalidOperationError: When message type is not registered and throw_on_invalid_type is True """ + def default_encoder(obj): + """Default JSON encoder that handles objects with __dict__.""" + if hasattr(obj, '__dict__'): + return obj.__dict__ + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + + # Merge user options with default encoder + json_options = self.json_options.copy() + if 'default' not in json_options: + json_options['default'] = default_encoder + for message in transaction.outgoing_messages: - # Serialize the message to JSON - message.body = json.dumps(message.message, **self.json_options) - message.headers[Headers.CONTENT_TYPE] = self.content_type - - # Set the message type header - header = message.bus.messages.get_header(message.message_type) - - if header is not None: - message.headers[Headers.MESSAGE_TYPE] = header - elif self.throw_on_invalid_type: - raise InvalidOperationError("The header has an invalid type.") + message.body = json.dumps(message.message, **json_options) + message.headers[self.header] = self.content_type - await next_handler() - - -class InvalidOperationError(Exception): - """Exception raised when an invalid operation is attempted.""" - pass \ No newline at end of file + await next_handler() \ No newline at end of file diff --git a/src/python/src/transport/transaction/message/incoming_message.py b/src/python/src/transport/transaction/message/incoming_message.py index 06a101c..f44d3c7 100644 --- a/src/python/src/transport/transaction/message/incoming_message.py +++ b/src/python/src/transport/transaction/message/incoming_message.py @@ -1,5 +1,6 @@ from typing import Any, Optional, Type from src.transport.transaction.message.message import Message +from src.transport.transaction.message.message_info import MessageInfo from src.i_poly_bus import IPolyBus @@ -8,14 +9,31 @@ class IncomingMessage(Message): Represents an incoming message from the transport. """ - def __init__(self, bus: "IPolyBus", body: str, message: Optional[Any] = None, message_type: Optional[Type] = None): + def __init__(self, bus: "IPolyBus", body: str, message_info: MessageInfo): super().__init__(bus) if body is None: raise ValueError("body cannot be None") + if message_info is None: + raise ValueError("message_info cannot be None") - self._message_type = message_type or str + self._message_info = message_info + self._message_type = bus.messages.get_type_by_message_info(message_info) self._body = body - self._message = message if message is not None else body + self._message = body + + @property + def message_info(self) -> MessageInfo: + """ + The message info describing metadata about the message. + """ + return self._message_info + + @message_info.setter + def message_info(self, value: MessageInfo) -> None: + """ + Set the message info. + """ + self._message_info = value @property def message_type(self) -> Type: diff --git a/src/python/src/transport/transaction/message/messages.py b/src/python/src/transport/transaction/message/messages.py index 2190676..a1f32c7 100644 --- a/src/python/src/transport/transaction/message/messages.py +++ b/src/python/src/transport/transaction/message/messages.py @@ -2,9 +2,10 @@ A collection of message types and their associated message headers. """ from threading import Lock -from typing import Dict, Optional, Type, Tuple +from typing import Dict, Type, Tuple import threading from src.transport.transaction.message.message_info import MessageInfo +from src.transport.transaction.message.poly_bus_message_not_found_error import PolyBusMessageNotFoundError class Messages: @@ -19,7 +20,7 @@ def __init__(self): self._types: Dict[Type, Tuple[MessageInfo, str]] = {} self._lock = threading.Lock() - def get_message_info(self, message_type: Type) -> Optional[MessageInfo]: + def get_message_info(self, message_type: Type) -> MessageInfo: """ Gets the message attribute associated with the specified type. @@ -27,54 +28,36 @@ def get_message_info(self, message_type: Type) -> Optional[MessageInfo]: message_type: The message type to get the attribute for Returns: - The MessageInfo if found, otherwise None + The MessageInfo associated with the specified type + + Raises: + PolyBusMessageNotFoundError: If no message attribute is found for the specified type """ with self._lock: entry = self._types.get(message_type) - return entry[0] if entry else None + if entry is None: + raise PolyBusMessageNotFoundError() + return entry[0] - def get_type_by_header(self, header: str) -> Optional[Type]: + def get_header_by_message_info(self, message_info: MessageInfo) -> str: """ - Attempts to get the message type associated with the specified header. + Gets the message header associated with the specified attribute. Args: - header: The message header to look up + message_info: The MessageInfo to get the header for Returns: - If found, returns the message type; otherwise, returns None. - """ - attribute = MessageInfo.get_attribute_from_header(header) - if attribute is None: - return None - - with self._lock: - # Check cache first - if header in self._map: - return self._map[header] + The message header associated with the specified attribute - # Find matching type - for msg_type, (msg_attribute, _) in self._types.items(): - if msg_attribute == attribute: - self._map[header] = msg_type - return msg_type - - # Cache miss result - self._map[header] = None - return None - - def get_header(self, message_type: Type) -> Optional[str]: - """ - Attempts to get the message header associated with the specified type. - - Args: - message_type: The message type to get the header for - - Returns: - If found, returns the message header; otherwise, returns None. + Raises: + PolyBusMessageNotFoundError: If no message header is found for the specified attribute """ with self._lock: - entry = self._types.get(message_type) - return entry[1] if entry else None + # Check if any type has this message info + for msg_type, (msg_attribute, header) in self._types.items(): + if msg_attribute == message_info: + return message_info.to_string(True) + raise PolyBusMessageNotFoundError() def add(self, message_type: Type) -> MessageInfo: """ @@ -88,40 +71,42 @@ def add(self, message_type: Type) -> MessageInfo: The MessageInfo associated with the message type Raises: - ValueError: If the type does not have a MessageInfo decorator - KeyError: If the type is already registered + PolyBusMessageNotFoundError: If the type does not have a MessageInfo decorator or is already registered """ # Check for MessageInfo attribute if not hasattr(message_type, '_message_info'): - raise ValueError(f"Type {message_type.__module__}.{message_type.__name__} does not have a MessageInfo decorator.") + raise PolyBusMessageNotFoundError() attribute = message_type._message_info if not isinstance(attribute, MessageInfo): - raise ValueError(f"Type {message_type.__module__}.{message_type.__name__} does not have a valid MessageInfo decorator.") + raise PolyBusMessageNotFoundError() header = attribute.to_string(True) with self._lock: if message_type in self._types: - raise KeyError(f"Type {message_type.__module__}.{message_type.__name__} is already registered.") + raise PolyBusMessageNotFoundError() self._types[message_type] = (attribute, header) self._map[header] = message_type return attribute - def get_type_by_message_info(self, message_info: MessageInfo) -> Optional[Type]: + def get_type_by_message_info(self, message_info: MessageInfo) -> Type: """ - Attempts to get the message type associated with the specified MessageInfo. + Gets the message type associated with the specified MessageInfo. Args: message_info: The MessageInfo to look up Returns: - If found, returns the message type; otherwise, returns None. + The message type associated with the specified attribute + + Raises: + PolyBusMessageNotFoundError: If no message type is found for the specified message info attribute """ with self._lock: for msg_type, (msg_attribute, _) in self._types.items(): if msg_attribute == message_info: return msg_type - return None + raise PolyBusMessageNotFoundError() diff --git a/src/python/src/transport/transaction/message/outgoing_message.py b/src/python/src/transport/transaction/message/outgoing_message.py index 8e365e7..9a4b944 100644 --- a/src/python/src/transport/transaction/message/outgoing_message.py +++ b/src/python/src/transport/transaction/message/outgoing_message.py @@ -1,6 +1,7 @@ from typing import Any, Optional, Type from datetime import datetime from src.transport.transaction.message.message import Message +from src.transport.transaction.message.message_info import MessageInfo from src.i_poly_bus import IPolyBus @@ -9,11 +10,18 @@ class OutgoingMessage(Message): Represents an outgoing message to the transport. """ - def __init__(self, bus: "IPolyBus", message: Any, endpoint: str): + def __init__( + self, + bus: "IPolyBus", + message: Any, + endpoint: Optional[str] = None, + message_info: Optional[MessageInfo] = None + ): super().__init__(bus) self._message = message self._message_type = type(message) - self._body = str(message) if message is not None else "" + self._message_info = message_info if message_info is not None else bus.messages.get_message_info(type(message)) + self._body = "" self._endpoint = endpoint self._deliver_at: Optional[datetime] = None @@ -31,6 +39,20 @@ def deliver_at(self, value: Optional[datetime]) -> None: """ self._deliver_at = value + @property + def message_info(self) -> Optional[MessageInfo]: + """ + The message info describing metadata about the message. + """ + return self._message_info + + @message_info.setter + def message_info(self, value: Optional[MessageInfo]) -> None: + """ + Set the message info. + """ + self._message_info = value + @property def message_type(self) -> Type: """ @@ -46,19 +68,18 @@ def message_type(self, value: Type) -> None: self._message_type = value @property - def body(self) -> str: + def endpoint(self) -> Optional[str]: """ - The serialized message body contents. + An optional location to explicitly send the message to. """ - return self._body + return self._endpoint - @body.setter - def body(self, value: str) -> None: + @endpoint.setter + def endpoint(self, value: Optional[str]) -> None: """ - Set the serialized message body contents. + Set the endpoint. """ - self._body = value - + self._endpoint = value @property def endpoint(self) -> str: """ diff --git a/src/python/src/transport/transaction/message/poly_bus_message_not_found_error.py b/src/python/src/transport/transaction/message/poly_bus_message_not_found_error.py new file mode 100644 index 0000000..de3b14b --- /dev/null +++ b/src/python/src/transport/transaction/message/poly_bus_message_not_found_error.py @@ -0,0 +1,11 @@ +""" +Exception raised when a requested type, attribute/decorator, or header is not found. +""" +from src.poly_bus_error import PolyBusError + + +class PolyBusMessageNotFoundError(PolyBusError): + """Thrown when a requested type, attribute/decorator, or header was not registered with the message system.""" + + def __init__(self): + super().__init__(2, "The requested type, attribute/decorator, or header was not found.") diff --git a/src/python/src/transport/transaction/outgoing_transaction_factory.py b/src/python/src/transport/transaction/outgoing_transaction_factory.py new file mode 100644 index 0000000..7ee1f19 --- /dev/null +++ b/src/python/src/transport/transaction/outgoing_transaction_factory.py @@ -0,0 +1,14 @@ +"""Outgoing transaction factory for creating outgoing transactions in the PolyBus Python implementation.""" + +from typing import Callable, Awaitable +from src.transport.transaction.outgoing_transaction import OutgoingTransaction + +OutgoingTransactionFactory = Callable[ + ['PolyBusBuilder', 'IPolyBus'], + Awaitable['OutgoingTransaction'] +] +""" +A method for creating a new transaction for processing a request. +This should be used to integrate with external transaction systems to ensure message processing +is done within the context of a transaction. +""" diff --git a/src/python/src/transport/transaction/transaction.py b/src/python/src/transport/transaction/transaction.py index 699aaa1..033d5fa 100644 --- a/src/python/src/transport/transaction/transaction.py +++ b/src/python/src/transport/transaction/transaction.py @@ -39,7 +39,7 @@ def outgoing_messages(self) -> List[OutgoingMessage]: """A list of outgoing messages to be sent when the transaction is committed.""" return self._outgoing_messages - def add_outgoing_message(self, message: Any, endpoint: Optional[str] = None) -> OutgoingMessage: + def add(self, message: Any, endpoint: Optional[str] = None) -> OutgoingMessage: """Add an outgoing message to this transaction. Args: @@ -52,17 +52,7 @@ def add_outgoing_message(self, message: Any, endpoint: Optional[str] = None) -> Raises: ValueError: If message type is not registered. """ - def get_endpoint() -> str: - message_info = self._bus.messages.get_message_info(type(message)) - if message_info is None: - raise ValueError(f"Message type {type(message).__name__} is not registered.") - return message_info.endpoint - - outgoing_message = OutgoingMessage( - self._bus, - message, - endpoint if endpoint is not None else get_endpoint() - ) + outgoing_message = OutgoingMessage(self._bus, message, endpoint) self._outgoing_messages.append(outgoing_message) return outgoing_message diff --git a/src/python/src/transport/transaction/transaction_factory.py b/src/python/src/transport/transaction/transaction_factory.py deleted file mode 100644 index 9b0a7b6..0000000 --- a/src/python/src/transport/transaction/transaction_factory.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Transaction factory for creating transactions in the PolyBus Python implementation.""" - -from typing import Callable, Optional, Awaitable -from src.transport.transaction import Transaction - -TransactionFactory = Callable[ - ['PolyBusBuilder', 'IPolyBus', Optional['IncomingMessage']], - Awaitable['Transaction'] -] -""" -A callable for creating a new transaction for processing a request. -This should be used to integrate with external transaction systems to ensure message processing -is done within the context of a transaction. - -Args: - builder: The PolyBus builder instance. - bus: The PolyBus instance. - message: The incoming message to process, if any. - -Returns: - An awaitable that resolves to a Transaction instance for processing the request. -""" diff --git a/src/python/tests/test_poly_bus.py b/src/python/tests/test_poly_bus.py deleted file mode 100644 index 6aaef11..0000000 --- a/src/python/tests/test_poly_bus.py +++ /dev/null @@ -1,405 +0,0 @@ -"""Tests for the PolyBus class. - -This test suite mirrors the functionality of the C# PolyBus tests, covering: - -1. Incoming message handlers: - - Basic handler invocation - - Handler invocation with delayed messages - - Handler invocation with exceptions (both with and without delays) - -2. Outgoing message handlers: - - Basic handler invocation - - Handler invocation with exceptions - -3. Bus functionality: - - Property access and configuration - - Transaction creation (both incoming and outgoing) - - Handler chain execution order - - Start/stop operations - - Exception handling and transaction abort - -These tests use mock objects to simulate the transport layer and message handling -without requiring the full infrastructure, making them fast and reliable for unit testing. -""" - -import asyncio -import pytest -from datetime import datetime, timedelta - -# Import the classes we need to test -from src.poly_bus_builder import PolyBusBuilder -from src.transport.transaction.incoming_transaction import IncomingTransaction -from src.transport.transaction.outgoing_transaction import OutgoingTransaction -from src.transport.transaction.message.incoming_message import IncomingMessage - - -class MockIncomingMessage(IncomingMessage): - """Mock incoming message for testing.""" - def __init__(self, bus, body): - super().__init__(bus, body) - - -class MockIncomingTransaction(IncomingTransaction): - """Mock incoming transaction for testing.""" - def __init__(self, bus, incoming_message): - # Initialize the parent IncomingTransaction class properly - super().__init__(bus, incoming_message) - - async def abort(self): - pass - - -class MockOutgoingTransaction(OutgoingTransaction): - """Mock outgoing transaction for testing.""" - def __init__(self, bus): - # Initialize the parent classes properly - super().__init__(bus) - - def add_outgoing_message(self, message, endpoint): - outgoing_message = MockOutgoingMessage(self.bus, message, endpoint) - self.outgoing_messages.append(outgoing_message) - return outgoing_message - - async def abort(self): - pass - - async def commit(self): - await self.bus.send(self) - - -class MockOutgoingMessage: - """Mock outgoing message for testing.""" - def __init__(self, bus, message, endpoint): - self.bus = bus - self.body = str(message) - self.endpoint = endpoint - self.deliver_at = None - self.headers = {} # Add headers attribute - self.message_type = "test" # Add message_type attribute - - -async def custom_transaction_factory(builder, bus, message=None): - """Custom transaction factory for testing.""" - if message is not None: - return MockIncomingTransaction(bus, message) - else: - return MockOutgoingTransaction(bus) - - -class TestPolyBus: - """Test suite for the PolyBus class.""" - - @pytest.mark.asyncio - async def test_incoming_handlers_is_invoked(self): - """Test that incoming handlers are invoked when processing messages.""" - # Arrange - incoming_transaction_future = asyncio.Future() - - async def incoming_handler(transaction, next_handler): - await next_handler() - incoming_transaction_future.set_result(transaction) - - builder = PolyBusBuilder() - builder.transaction_factory = custom_transaction_factory - builder.incoming_handlers.append(incoming_handler) - bus = await builder.build() - - # Act - await bus.start() - - # Create an incoming message and transaction to simulate receiving a message - incoming_message = MockIncomingMessage(bus, "Hello world") - incoming_transaction = MockIncomingTransaction(bus, incoming_message) - await bus.send(incoming_transaction) - - transaction = await incoming_transaction_future - await bus.stop() - - # Assert - assert transaction.incoming_message.body == "Hello world" - - @pytest.mark.asyncio - async def test_incoming_handlers_with_delay_is_invoked(self): - """Test that incoming handlers are invoked when processing delayed messages.""" - # Arrange - processed_on_future = asyncio.Future() - - async def incoming_handler(transaction, next_handler): - await next_handler() - processed_on_future.set_result(datetime.utcnow()) - - builder = PolyBusBuilder() - builder.transaction_factory = custom_transaction_factory - builder.incoming_handlers.append(incoming_handler) - bus = await builder.build() - - # Act - await bus.start() - outgoing_transaction = await bus.create_transaction() - message = outgoing_transaction.add_outgoing_message("Hello world", "unknown-endpoint") - scheduled_at = datetime.utcnow() + timedelta(seconds=5) - message.deliver_at = scheduled_at - - # Simulate delayed processing - await asyncio.sleep(0.01) # Small delay to simulate processing time - incoming_message = MockIncomingMessage(bus, "Hello world") - incoming_transaction = MockIncomingTransaction(bus, incoming_message) - await bus.send(incoming_transaction) - - processed_on = await processed_on_future - await bus.stop() - - # Assert - # In this test, we're not actually implementing delay functionality, - # but we're testing that the handler gets called - assert processed_on is not None - assert isinstance(processed_on, datetime) - - @pytest.mark.asyncio - async def test_incoming_handlers_with_delay_and_exception_is_invoked(self): - """Test that incoming handlers are invoked even when exceptions occur during delayed processing.""" - # Arrange - processed_on_future = asyncio.Future() - - async def incoming_handler(transaction, next_handler): - processed_on_future.set_result(datetime.utcnow()) - raise Exception(transaction.incoming_message.body) - - builder = PolyBusBuilder() - builder.transaction_factory = custom_transaction_factory - builder.incoming_handlers.append(incoming_handler) - bus = await builder.build() - - # Act - await bus.start() - outgoing_transaction = await bus.create_transaction() - message = outgoing_transaction.add_outgoing_message("Hello world", "unknown-endpoint") - scheduled_at = datetime.utcnow() + timedelta(seconds=5) - message.deliver_at = scheduled_at - - # Simulate processing with exception - incoming_message = MockIncomingMessage(bus, "Hello world") - incoming_transaction = MockIncomingTransaction(bus, incoming_message) - - with pytest.raises(Exception) as exc_info: - await bus.send(incoming_transaction) - - processed_on = await processed_on_future - await bus.stop() - - # Assert - assert processed_on is not None - assert str(exc_info.value) == "Hello world" - - @pytest.mark.asyncio - async def test_incoming_handlers_with_exception_is_invoked(self): - """Test that incoming handlers are invoked and exceptions are properly handled.""" - # Arrange - incoming_transaction_future = asyncio.Future() - - def incoming_handler(transaction, next_handler): - incoming_transaction_future.set_result(transaction) - raise Exception(transaction.incoming_message.body) - - builder = PolyBusBuilder() - builder.transaction_factory = custom_transaction_factory - builder.incoming_handlers.append(incoming_handler) - bus = await builder.build() - - # Act - await bus.start() - - incoming_message = MockIncomingMessage(bus, "Hello world") - incoming_transaction = MockIncomingTransaction(bus, incoming_message) - - with pytest.raises(Exception) as exc_info: - await bus.send(incoming_transaction) - - transaction = await incoming_transaction_future - await bus.stop() - - # Assert - assert transaction.incoming_message.body == "Hello world" - assert str(exc_info.value) == "Hello world" - - @pytest.mark.asyncio - async def test_outgoing_handlers_is_invoked(self): - """Test that outgoing handlers are invoked when processing outgoing messages.""" - # Arrange - outgoing_transaction_future = asyncio.Future() - - async def outgoing_handler(transaction, next_handler): - await next_handler() - outgoing_transaction_future.set_result(transaction) - - builder = PolyBusBuilder() - builder.transaction_factory = custom_transaction_factory - builder.outgoing_handlers.append(outgoing_handler) - bus = await builder.build() - - # Act - await bus.start() - outgoing_transaction = await bus.create_transaction() - outgoing_transaction.add_outgoing_message("Hello world", "unknown-endpoint") - await outgoing_transaction.commit() - - transaction = await outgoing_transaction_future - await bus.stop() - - # Assert - assert len(transaction.outgoing_messages) == 1 - assert transaction.outgoing_messages[0].body == "Hello world" - - @pytest.mark.asyncio - async def test_outgoing_handlers_with_exception_is_invoked(self): - """Test that outgoing handlers are invoked and exceptions are properly handled.""" - # Arrange - def outgoing_handler(transaction, next_handler): - raise Exception(transaction.outgoing_messages[0].body) - - builder = PolyBusBuilder() - builder.transaction_factory = custom_transaction_factory - builder.outgoing_handlers.append(outgoing_handler) - bus = await builder.build() - - # Act - await bus.start() - outgoing_transaction = await bus.create_transaction() - outgoing_transaction.add_outgoing_message("Hello world", "unknown-endpoint") - - with pytest.raises(Exception) as exc_info: - await outgoing_transaction.commit() - - await bus.stop() - - # Assert - assert str(exc_info.value) == "Hello world" - - @pytest.mark.asyncio - async def test_bus_properties_are_accessible(self): - """Test that bus properties are accessible and properly configured.""" - # Arrange - builder = PolyBusBuilder() - builder.properties["test_key"] = "test_value" - builder.name = "TestBus" - bus = await builder.build() - - # Assert - assert bus.properties["test_key"] == "test_value" - assert bus.name == "TestBus" - assert bus.transport is not None - assert bus.incoming_handlers == builder.incoming_handlers - assert bus.outgoing_handlers == builder.outgoing_handlers - assert bus.messages == builder.messages - - @pytest.mark.asyncio - async def test_create_transaction_without_message(self): - """Test creating an outgoing transaction without a message.""" - # Arrange - builder = PolyBusBuilder() - bus = await builder.build() - - # Act - transaction = await bus.create_transaction() - - # Assert - assert transaction is not None - assert transaction.bus == bus - assert hasattr(transaction, 'outgoing_messages') - - @pytest.mark.asyncio - async def test_create_transaction_with_message(self): - """Test creating an incoming transaction with a message.""" - # Arrange - builder = PolyBusBuilder() - builder.transaction_factory = custom_transaction_factory - bus = await builder.build() - incoming_message = MockIncomingMessage(bus, "Test message") - - # Act - transaction = await bus.create_transaction(incoming_message) - - # Assert - assert transaction is not None - assert transaction.bus == bus - assert hasattr(transaction, 'incoming_message') - - @pytest.mark.asyncio - async def test_handler_chain_execution_order(self): - """Test that handlers are executed in the correct order.""" - # Arrange - execution_order = [] - - async def handler1(transaction, next_handler): - execution_order.append("handler1_start") - await next_handler() - execution_order.append("handler1_end") - - async def handler2(transaction, next_handler): - execution_order.append("handler2_start") - await next_handler() - execution_order.append("handler2_end") - - builder = PolyBusBuilder() - builder.transaction_factory = custom_transaction_factory - builder.outgoing_handlers.extend([handler1, handler2]) - bus = await builder.build() - - # Act - await bus.start() - outgoing_transaction = await bus.create_transaction() - outgoing_transaction.add_outgoing_message("Test message", "test-endpoint") - await outgoing_transaction.commit() - await bus.stop() - - # Assert - # Handlers execute in order, but nested (like middleware) - # handler1 starts, calls next (handler2), handler2 completes, then handler1 completes - expected_order = ["handler1_start", "handler2_start", "handler2_end", "handler1_end"] - assert execution_order == expected_order - - @pytest.mark.asyncio - async def test_bus_start_and_stop(self): - """Test that bus can be started and stopped properly.""" - # Arrange - builder = PolyBusBuilder() - bus = await builder.build() - - # Act & Assert - await bus.start() # Should not raise an exception - await bus.stop() # Should not raise an exception - - @pytest.mark.asyncio - async def test_transaction_abort_on_exception(self): - """Test that transaction.abort() is called when an exception occurs.""" - # Arrange - abort_called = asyncio.Future() - - class MockTransactionWithAbort(OutgoingTransaction): - def __init__(self, bus): - super().__init__(bus) - - async def abort(self): - abort_called.set_result(True) - - async def mock_transaction_factory(builder, bus, message=None): - return MockTransactionWithAbort(bus) - - async def failing_handler(transaction, next_handler): - raise Exception("Test exception") - - builder = PolyBusBuilder() - builder.transaction_factory = mock_transaction_factory - builder.outgoing_handlers.append(failing_handler) - bus = await builder.build() - - # Act - await bus.start() - transaction = await bus.create_transaction() - - with pytest.raises(Exception): - await bus.send(transaction) - - # Assert - assert await abort_called == True - await bus.stop() \ No newline at end of file diff --git a/src/python/tests/transport/in_memory/test_in_memory_transport.py b/src/python/tests/transport/in_memory/test_in_memory_transport.py index 57446f4..89b82a5 100644 --- a/src/python/tests/transport/in_memory/test_in_memory_transport.py +++ b/src/python/tests/transport/in_memory/test_in_memory_transport.py @@ -1,10 +1,18 @@ -"""Tests for InMemoryTransport - Python equivalent of C# InMemoryTests. +"""Tests for InMemoryTransport - Python equivalent of C# InMemoryTransportTests. -This test suite mirrors the C# InMemoryTests.cs functionality using the actual +This test suite mirrors the C# InMemoryTransportTests.cs functionality using the actual PolyBus implementation components (not mocks). The tests cover: -1. Message flow with subscription filtering (equivalent to C# InMemory_WithSubscription test) +1. Sending messages before starting (should throw error) +2. Sending messages after starting +3. Sending messages to explicit endpoints +4. Sending messages with custom headers +5. Sending messages with delays +6. Sending messages with expired delays +7. Starting when already started +8. Subscribing before starting (should throw error) +9. Subscribing after starting Key differences from C# version: - Uses async/await patterns instead of Task-based async @@ -13,77 +21,321 @@ """ import pytest +import pytest_asyncio import asyncio +import time +from datetime import datetime, timezone, timedelta +from typing import Callable, Optional, Dict from src.poly_bus_builder import PolyBusBuilder -from src.transport.in_memory.in_memory_transport import InMemoryTransport +from src.transport.in_memory.in_memory_message_broker import InMemoryMessageBroker +from src.transport.in_memory.in_memory_endpoint import InMemoryEndpoint from src.transport.transaction.message.message_info import MessageInfo from src.transport.transaction.message.message_type import MessageType +from src.transport.transaction.incoming_transaction import IncomingTransaction +from src.transport.poly_bus_not_started_error import PolyBusNotStartedError +from src.transport.transaction.message.handlers.serializers.json_handlers import JsonHandlers from src.headers import Headers +from src.i_poly_bus import IPolyBus -@MessageInfo(MessageType.COMMAND, "test-service", "TestMessage", 1, 0, 0) -class SampleMessage: - """Test message class decorated with MessageInfo.""" +@MessageInfo(MessageType.COMMAND, "alpha", "alpha-command", 1, 0, 0) +class AlphaCommand(dict): + """Test command message class for alpha endpoint.""" def __init__(self, name: str = ""): + super().__init__(name=name) self.name = name + + +@MessageInfo(MessageType.EVENT, "alpha", "alpha-event", 1, 0, 0) +class AlphaEvent(dict): + """Test event message class for alpha endpoint.""" + + def __init__(self, name: str = ""): + super().__init__(name=name) + self.name = name + + +class _TestEndpoint: + """Helper class representing a test endpoint with its bus and handlers.""" + + def __init__(self): + self.on_message_received: Callable[[IncomingTransaction], None] = lambda _: None + self.bus: Optional[IPolyBus] = None + self.builder = PolyBusBuilder() - def __str__(self): - return self.name + @property + def transport(self) -> InMemoryEndpoint: + """Get the transport as an InMemoryEndpoint.""" + return self.bus.transport + + async def handler(self, transaction: IncomingTransaction, next_step: Callable): + """Handler that calls the on_message_received callback.""" + await self.on_message_received(transaction) + await next_step() + + +class _TestEnvironment: + """Test environment that sets up alpha and beta endpoints.""" + + def __init__(self): + self.in_memory_message_broker = InMemoryMessageBroker() + self.alpha = _TestEndpoint() + self.beta = _TestEndpoint() + + async def setup(self) -> None: + """Set up both alpha and beta endpoints.""" + await self._setup_endpoint(self.alpha, "alpha") + await self._setup_endpoint(self.beta, "beta") + + async def _setup_endpoint(self, test_endpoint: _TestEndpoint, name: str) -> None: + """Set up a single endpoint. + + Args: + test_endpoint: The endpoint to set up + name: The name for the endpoint + """ + json_handlers = JsonHandlers() + + # Add handlers for incoming messages + test_endpoint.builder.incoming_pipeline.append(json_handlers.deserializer) + test_endpoint.builder.incoming_pipeline.append(test_endpoint.handler) + + # Add messages + test_endpoint.builder.messages.add(AlphaCommand) + test_endpoint.builder.messages.add(AlphaEvent) + test_endpoint.builder.name = name + + # Add handlers for outgoing messages + test_endpoint.builder.outgoing_pipeline.append(json_handlers.serializer) + + # Configure InMemory transport + test_endpoint.builder.transport_factory = self.in_memory_message_broker.add_endpoint + + # Create the bus instance + test_endpoint.bus = await test_endpoint.builder.build() + + async def start(self) -> None: + """Start both alpha and beta endpoints.""" + await self.alpha.bus.start() + await self.beta.bus.start() + + async def stop(self) -> None: + """Stop both alpha and beta endpoints.""" + await self.alpha.bus.stop() + await self.beta.bus.stop() class TestInMemoryTransport: - """Test cases for InMemoryTransport that mirror C# InMemoryTests.""" + """Test cases for InMemoryTransport that mirror C# InMemoryTransportTests.""" + + @pytest_asyncio.fixture + async def test_environment(self): + """Create and set up a test environment.""" + env = _TestEnvironment() + await env.setup() + yield env + await env.stop() @pytest.mark.asyncio - async def test_in_memory_with_subscription(self): - """Test InMemoryTransport with subscription - mirrors C# InMemory_WithSubscription test. + async def test_send_before_starting(self, test_environment: _TestEnvironment): + """Test sending a message before starting the bus - should throw an error.""" + # Arrange + transaction = await test_environment.beta.bus.create_outgoing_transaction() + task_completion_source = asyncio.Future() - This test validates the complete message flow through InMemoryTransport - when subscriptions are enabled, matching the C# test behavior exactly. - """ + def on_message_received(incoming_transaction): + # This should not be called + task_completion_source.set_result(True) + + test_environment.alpha.on_message_received = on_message_received + + # Act + transaction.add(AlphaCommand(name="Test")) + + # Assert - should throw an error because the transport is not started + with pytest.raises(PolyBusNotStartedError): + await transaction.commit() + + assert not task_completion_source.done() + + @pytest.mark.asyncio + async def test_send_after_started(self, test_environment: _TestEnvironment): + """Test sending a message after starting the bus.""" + # Arrange + transaction = await test_environment.beta.bus.create_outgoing_transaction() + task_completion_source = asyncio.Future() + + def on_message_received(incoming_transaction): + task_completion_source.set_result(True) + + test_environment.alpha.on_message_received = on_message_received + + # Act - send a command from the beta endpoint to alpha endpoint + await test_environment.start() + transaction.add(AlphaCommand(name="Test")) + await transaction.commit() + await asyncio.wait_for(task_completion_source, timeout=1.0) + + # Assert + assert task_completion_source.done() + + @pytest.mark.asyncio + async def test_send_with_explicit_endpoint(self, test_environment: _TestEnvironment): + """Test sending a message to an explicit endpoint (dead letter queue).""" + # Arrange + transaction = await test_environment.alpha.bus.create_outgoing_transaction() + task_completion_source = asyncio.Future() + + def on_message_received(incoming_transaction): + # This should NOT be called + task_completion_source.set_result(incoming_transaction.bus.name) + + def dead_letter_handler(message): + # This should be called + task_completion_source.set_result(test_environment.alpha.transport.dead_letter_endpoint) + + test_environment.alpha.on_message_received = on_message_received + test_environment.alpha.transport.dead_letter_handler = dead_letter_handler + endpoint = test_environment.alpha.transport.dead_letter_endpoint + + # Act - send the alpha command to dead letter endpoint + await test_environment.start() + transaction.add(AlphaCommand(name="Test"), endpoint=endpoint) + await transaction.commit() + actual_endpoint = await asyncio.wait_for(task_completion_source, timeout=1.0) + + # Assert + assert actual_endpoint == endpoint + + @pytest.mark.asyncio + async def test_send_with_headers(self, test_environment: _TestEnvironment): + """Test sending a message with custom headers.""" # Arrange - in_memory_transport = InMemoryTransport() - in_memory_transport.use_subscriptions = True + header_key = "X-Custom-Header" + header_value = "HeaderValue" + transaction = await test_environment.alpha.bus.create_outgoing_transaction() + task_completion_source = asyncio.Future() + + def on_message_received(incoming_transaction): + value = incoming_transaction.incoming_message.headers.get(header_key, "") + task_completion_source.set_result(value) + + test_environment.alpha.on_message_received = on_message_received - incoming_transaction_future = asyncio.Future() + # Act - send a command with a custom header + await test_environment.start() + message = transaction.add(AlphaCommand(name="Test")) + message.headers[header_key] = header_value + await transaction.commit() + actual_header_value = await asyncio.wait_for(task_completion_source, timeout=1.0) - async def incoming_handler(transaction, next_step): - """Incoming handler that captures the transaction.""" - incoming_transaction_future.set_result(transaction) - await next_step() + # Assert + assert actual_header_value == header_value + + @pytest.mark.asyncio + async def test_send_with_delay(self, test_environment: _TestEnvironment): + """Test sending a message with a delay.""" + # Arrange + delay_ms = 500 # 0.5 seconds (shorter than C# for faster tests) + transaction = await test_environment.alpha.bus.create_outgoing_transaction() + task_completion_source = asyncio.Future() - async def transport_factory(b, bus): - return in_memory_transport.add_endpoint(b, bus) + def on_message_received(incoming_transaction): + elapsed = time.time() - start_time + task_completion_source.set_result(elapsed) - builder = PolyBusBuilder() - builder.incoming_handlers.append(incoming_handler) - builder.transport_factory = transport_factory - builder.messages.add(SampleMessage) + test_environment.alpha.on_message_received = on_message_received - bus = await builder.build() + # Act - send with delay + await test_environment.start() + message = transaction.add(AlphaCommand(name="Test")) + message.deliver_at = datetime.now(timezone.utc) + timedelta(milliseconds=delay_ms) + start_time = time.time() + await transaction.commit() + elapsed = await asyncio.wait_for(task_completion_source, timeout=2.0) - # Get message info from the SampleMessage class - message_info = SampleMessage._message_info + # Assert - allow 200ms of leeway on both sides + assert elapsed >= (delay_ms / 1000.0) - 0.2 + assert elapsed <= (delay_ms / 1000.0) + 0.2 + + @pytest.mark.asyncio + async def test_send_with_expired_delay(self, test_environment: _TestEnvironment): + """Test sending a message with an expired delay (in the past).""" + # Arrange + transaction = await test_environment.alpha.bus.create_outgoing_transaction() + task_completion_source = asyncio.Future() - # Subscribe to the message type - await bus.transport.subscribe(message_info) + def on_message_received(incoming_transaction): + task_completion_source.set_result(True) + test_environment.alpha.on_message_received = on_message_received + + # Act - schedule command to be delivered in the past + await test_environment.start() + message = transaction.add(AlphaCommand(name="Test")) + message.deliver_at = datetime.now(timezone.utc) - timedelta(seconds=1) + await transaction.commit() + await asyncio.wait_for(task_completion_source, timeout=1.0) + + # Assert + assert task_completion_source.done() + + @pytest.mark.asyncio + async def test_start_when_already_started(self, test_environment: _TestEnvironment): + """Test starting the bus when it's already started - should not throw an error.""" # Act - await bus.start() - outgoing_transaction = await bus.create_transaction() - outgoing_message = outgoing_transaction.add_outgoing_message(SampleMessage(name="TestMessage")) - outgoing_message.headers[Headers.MESSAGE_TYPE] = message_info.to_string(True) - await outgoing_transaction.commit() + await test_environment.start() + + # Assert - starting again should not throw an error + await test_environment.start() # Should not raise + + @pytest.mark.asyncio + async def test_subscribe_before_started(self, test_environment: _TestEnvironment): + """Test subscribing before starting - should throw an error.""" + # Arrange + transaction = await test_environment.alpha.bus.create_outgoing_transaction() + task_completion_source = asyncio.Future() + + def on_message_received(incoming_transaction): + task_completion_source.set_result(True) + + test_environment.beta.on_message_received = on_message_received + + # Act - subscribing before starting should throw an error + with pytest.raises(PolyBusNotStartedError): + await test_environment.beta.transport.subscribe( + test_environment.beta.bus.messages.get_message_info(AlphaEvent) + ) + + transaction.add(AlphaEvent(name="Test")) + + with pytest.raises(PolyBusNotStartedError): + await transaction.commit() + + # Assert + assert not task_completion_source.done() + + @pytest.mark.asyncio + async def test_subscribe(self, test_environment: _TestEnvironment): + """Test subscribing to events after starting.""" + # Arrange + transaction = await test_environment.alpha.bus.create_outgoing_transaction() + task_completion_source = asyncio.Future() - # Wait for incoming transaction (equivalent to TaskCompletionSource in C#) - incoming_transaction = await asyncio.wait_for(incoming_transaction_future, timeout=1.0) + def on_message_received(incoming_transaction): + task_completion_source.set_result(True) - # Allow async processing to complete - await asyncio.sleep(0.01) + test_environment.beta.on_message_received = on_message_received + await test_environment.start() - await bus.stop() + # Act - subscribe and send event + await test_environment.beta.transport.subscribe( + test_environment.beta.bus.messages.get_message_info(AlphaEvent) + ) + transaction.add(AlphaEvent(name="Test")) + await transaction.commit() + await asyncio.wait_for(task_completion_source, timeout=1.0) # Assert - assert incoming_transaction.incoming_message.body == "TestMessage" - assert incoming_transaction.incoming_message.headers[Headers.MESSAGE_TYPE] == message_info.to_string(True) \ No newline at end of file + assert task_completion_source.done() \ No newline at end of file diff --git a/src/python/tests/transport/transaction/message/handlers/error/test_error_handlers.py b/src/python/tests/transport/transaction/message/handlers/error/test_error_handlers.py index 48c8805..5d883bd 100644 --- a/src/python/tests/transport/transaction/message/handlers/error/test_error_handlers.py +++ b/src/python/tests/transport/transaction/message/handlers/error/test_error_handlers.py @@ -2,22 +2,21 @@ import pytest from datetime import datetime, timedelta, timezone -from unittest.mock import Mock from typing import List, Dict, Any, Optional from src.transport.transaction.message.handlers.error.error_handlers import ErrorHandler from src.transport.transaction.incoming_transaction import IncomingTransaction from src.transport.transaction.message.incoming_message import IncomingMessage +from src.transport.transaction.message.message_info import MessageInfo +from src.transport.transaction.message.message_type import MessageType from src.transport.transaction.transaction import Transaction -from src.transport.transaction.outgoing_transaction import OutgoingTransaction -class MockableErrorHandler(ErrorHandler): - """Mockable version of ErrorHandler with overridable retry time.""" +class TestableErrorHandler(ErrorHandler): + """Testable version of ErrorHandler with overridable retry time.""" - def __init__(self, delay: int = 30, delayed_retry_count: int = 3, - immediate_retry_count: int = 3, dead_letter_endpoint: Optional[str] = None): - super().__init__(delay, delayed_retry_count, immediate_retry_count, dead_letter_endpoint) + def __init__(self): + super().__init__() self._next_retry_time: Optional[datetime] = None def set_next_retry_time(self, retry_time: datetime) -> None: @@ -31,25 +30,34 @@ def get_next_retry_time(self, attempt: int) -> datetime: return super().get_next_retry_time(attempt) -class MockTransport: +class TestTransport: """Mock implementation of ITransport.""" + DEFAULT_DEAD_LETTER_ENDPOINT = "dead-letters" + + @property + def dead_letter_endpoint(self) -> str: + return self.DEFAULT_DEAD_LETTER_ENDPOINT + @property def supports_command_messages(self) -> bool: return True @property - def supports_delayed_messages(self) -> bool: + def supports_delayed_commands(self) -> bool: return True @property def supports_subscriptions(self) -> bool: return False + async def handle(self, transaction: Transaction) -> None: + raise NotImplementedError() + async def send(self, transaction: Transaction) -> None: pass - async def subscribe(self, message_info) -> None: + async def subscribe(self, message_info: MessageInfo) -> None: pass async def start(self) -> None: @@ -59,15 +67,45 @@ async def stop(self) -> None: pass -class MockBus: +@MessageInfo(MessageType.COMMAND, "polybus", "error-handler-test-message", 1, 0, 0) +class ErrorHandlerTestMessage: + """Test message class.""" + pass + + +class Messages: + """Mock Messages registry.""" + + def __init__(self): + self._messages = [] + self._types = {} + + def add(self, message_type: type) -> None: + """Add a message type to the registry.""" + self._messages.append(message_type) + if hasattr(message_type, '_message_info'): + self._types[message_type._message_info] = message_type + + def get_message_info(self, message_type: type) -> MessageInfo: + """Get message info for a type.""" + if hasattr(message_type, '_message_info'): + return message_type._message_info + raise ValueError(f"Message type {message_type} not registered") + + def get_type_by_message_info(self, message_info: MessageInfo) -> type: + """Get type for a message info.""" + return self._types.get(message_info, str) + + +class TestBus: """Mock implementation of IPolyBus.""" def __init__(self, name: str): self._name = name - self._transport = MockTransport() - self._incoming_handlers = [] - self._outgoing_handlers = [] - self._messages = Mock() + self._transport = TestTransport() + self._incoming_pipeline = [] + self._outgoing_pipeline = [] + self._messages = Messages() self._properties = {} @property @@ -75,30 +113,32 @@ def properties(self) -> Dict[str, Any]: return self._properties @property - def transport(self) -> MockTransport: + def transport(self) -> TestTransport: return self._transport @property - def incoming_handlers(self) -> List: - return self._incoming_handlers + def incoming_pipeline(self) -> List: + return self._incoming_pipeline @property - def outgoing_handlers(self) -> List: - return self._outgoing_handlers + def outgoing_pipeline(self) -> List: + return self._outgoing_pipeline @property - def messages(self) -> Mock: + def messages(self) -> Messages: return self._messages @property def name(self) -> str: return self._name - async def create_transaction(self, message: Optional[IncomingMessage] = None) -> Transaction: - if message is None: - return OutgoingTransaction(self) + async def create_incoming_transaction(self, message: IncomingMessage) -> IncomingTransaction: return IncomingTransaction(self, message) + async def create_outgoing_transaction(self): + from src.transport.transaction.outgoing_transaction import OutgoingTransaction + return OutgoingTransaction(self) + async def send(self, transaction: Transaction) -> None: pass @@ -110,24 +150,25 @@ async def stop(self) -> None: class ExceptionWithNullStackTrace(Exception): - """Custom exception that returns None for __traceback__.""" + """Custom exception that simulates null stack trace.""" def __init__(self, message: str): super().__init__(message) - # Simulate an exception without stack trace - self.__traceback__ = None @pytest.fixture def test_bus(): """Create a test bus instance.""" - return MockBus("TestBus") + bus = TestBus("TestBus") + bus.messages.add(ErrorHandlerTestMessage) + return bus @pytest.fixture def incoming_message(test_bus): """Create a test incoming message.""" - return IncomingMessage(test_bus, "test message body") + message_info = test_bus.messages.get_message_info(ErrorHandlerTestMessage) + return IncomingMessage(test_bus, "{}", message_info) @pytest.fixture @@ -139,7 +180,7 @@ def transaction(test_bus, incoming_message): @pytest.fixture def error_handler(): """Create a testable error handler.""" - return MockableErrorHandler() + return TestableErrorHandler() class TestErrorHandler: @@ -178,7 +219,7 @@ async def next_handler(): @pytest.mark.asyncio async def test_retrier_fails_all_immediate_retries_schedules_delayed_retry(self, transaction, error_handler): """Test that failing all immediate retries schedules a delayed retry.""" - expected_retry_time = datetime.utcnow() + timedelta(minutes=5) + expected_retry_time = datetime.now(timezone.utc) + timedelta(minutes=5) error_handler.set_next_retry_time(expected_retry_time) call_count = 0 @@ -195,14 +236,14 @@ async def next_handler(): delayed_message = transaction.outgoing_messages[0] assert delayed_message.deliver_at == expected_retry_time - assert delayed_message.headers[ErrorHandler.RETRY_COUNT_HEADER] == "1" + assert delayed_message.headers[error_handler.retry_count_header] == "1" assert delayed_message.endpoint == "TestBus" @pytest.mark.asyncio async def test_retrier_with_existing_retry_count_increments_correctly(self, transaction, error_handler): """Test that existing retry count is incremented correctly.""" - transaction.incoming_message.headers[ErrorHandler.RETRY_COUNT_HEADER] = "2" - expected_retry_time = datetime.utcnow() + timedelta(minutes=10) + transaction.incoming_message.headers[error_handler.retry_count_header] = "2" + expected_retry_time = datetime.now(timezone.utc) + timedelta(minutes=10) error_handler.set_next_retry_time(expected_retry_time) async def next_handler(): @@ -213,13 +254,13 @@ async def next_handler(): assert len(transaction.outgoing_messages) == 1 delayed_message = transaction.outgoing_messages[0] - assert delayed_message.headers[ErrorHandler.RETRY_COUNT_HEADER] == "3" + assert delayed_message.headers[error_handler.retry_count_header] == "3" assert delayed_message.deliver_at == expected_retry_time @pytest.mark.asyncio async def test_retrier_exceeds_max_delayed_retries_sends_to_dead_letter(self, transaction, error_handler): """Test that exceeding max delayed retries sends to dead letter queue.""" - transaction.incoming_message.headers[ErrorHandler.RETRY_COUNT_HEADER] = str(error_handler.delayed_retry_count) + transaction.incoming_message.headers[error_handler.retry_count_header] = str(error_handler.delayed_retry_count) test_exception = Exception("Final error") @@ -231,25 +272,11 @@ async def next_handler(): assert len(transaction.outgoing_messages) == 1 dead_letter_message = transaction.outgoing_messages[0] - assert dead_letter_message.endpoint == "TestBus.Errors" - assert dead_letter_message.headers[ErrorHandler.ERROR_MESSAGE_HEADER] == "Final error" - assert dead_letter_message.headers[ErrorHandler.ERROR_STACK_TRACE_HEADER] is not None + assert dead_letter_message.endpoint == TestTransport.DEFAULT_DEAD_LETTER_ENDPOINT + assert dead_letter_message.headers[error_handler.error_message_header] == "Final error" + assert dead_letter_message.headers[error_handler.error_stack_trace_header] is not None - @pytest.mark.asyncio - async def test_retrier_with_custom_dead_letter_endpoint_uses_custom_endpoint(self, transaction): - """Test that custom dead letter endpoint is used.""" - error_handler = MockableErrorHandler(dead_letter_endpoint="CustomDeadLetter") - transaction.incoming_message.headers[ErrorHandler.RETRY_COUNT_HEADER] = str(error_handler.delayed_retry_count) - - async def next_handler(): - raise Exception("Final error") - - await error_handler.retrier(transaction, next_handler) - - assert len(transaction.outgoing_messages) == 1 - - dead_letter_message = transaction.outgoing_messages[0] - assert dead_letter_message.endpoint == "CustomDeadLetter" + @pytest.mark.asyncio async def test_retrier_clears_outgoing_messages_on_each_retry(self, transaction, error_handler): @@ -259,7 +286,7 @@ async def test_retrier_clears_outgoing_messages_on_each_retry(self, transaction, async def next_handler(): nonlocal call_count call_count += 1 - transaction.add_outgoing_message("some message", "some endpoint") + transaction.add(ErrorHandlerTestMessage()) raise Exception("Test error") await error_handler.retrier(transaction, next_handler) @@ -267,13 +294,14 @@ async def next_handler(): assert call_count == error_handler.immediate_retry_count # Should only have the delayed retry message, not the messages added in next_handler assert len(transaction.outgoing_messages) == 1 - assert ErrorHandler.RETRY_COUNT_HEADER in transaction.outgoing_messages[0].headers + assert error_handler.retry_count_header in transaction.outgoing_messages[0].headers @pytest.mark.asyncio async def test_retrier_with_zero_immediate_retries_schedules_delayed_retry_immediately(self, transaction): """Test that zero immediate retries still enforces minimum of 1.""" - error_handler = MockableErrorHandler(immediate_retry_count=0) - expected_retry_time = datetime.utcnow() + timedelta(minutes=5) + error_handler = TestableErrorHandler() + error_handler.immediate_retry_count = 0 + expected_retry_time = datetime.now(timezone.utc) + timedelta(minutes=5) error_handler.set_next_retry_time(expected_retry_time) call_count = 0 @@ -287,13 +315,14 @@ async def next_handler(): assert call_count == 1 # Should enforce minimum of 1 assert len(transaction.outgoing_messages) == 1 - assert transaction.outgoing_messages[0].headers[ErrorHandler.RETRY_COUNT_HEADER] == "1" + assert transaction.outgoing_messages[0].headers[error_handler.retry_count_header] == "1" @pytest.mark.asyncio async def test_retrier_with_zero_delayed_retries_still_gets_minimum_of_one(self, transaction): """Test that zero delayed retries still enforces minimum of 1.""" - error_handler = MockableErrorHandler(delayed_retry_count=0) - expected_retry_time = datetime.utcnow() + timedelta(minutes=5) + error_handler = TestableErrorHandler() + error_handler.delayed_retry_count = 0 + expected_retry_time = datetime.now(timezone.utc) + timedelta(minutes=5) error_handler.set_next_retry_time(expected_retry_time) async def next_handler(): @@ -303,12 +332,13 @@ async def next_handler(): # Even with delayed_retry_count = 0, max(1, delayed_retry_count) makes it 1 assert len(transaction.outgoing_messages) == 1 - assert transaction.outgoing_messages[0].headers[ErrorHandler.RETRY_COUNT_HEADER] == "1" + assert transaction.outgoing_messages[0].headers[error_handler.retry_count_header] == "1" assert transaction.outgoing_messages[0].deliver_at == expected_retry_time def test_get_next_retry_time_default_implementation_uses_delay_correctly(self): """Test that GetNextRetryTime uses delay correctly.""" - handler = ErrorHandler(delay=60) + handler = ErrorHandler() + handler.delay_increment = 60 before_time = datetime.now(timezone.utc) result1 = handler.get_next_retry_time(1) @@ -345,8 +375,8 @@ async def next_handler(): @pytest.mark.asyncio async def test_retrier_invalid_retry_count_header_treats_as_zero(self, transaction, error_handler): """Test that invalid retry count header is treated as zero.""" - transaction.incoming_message.headers[ErrorHandler.RETRY_COUNT_HEADER] = "invalid" - expected_retry_time = datetime.utcnow() + timedelta(minutes=5) + transaction.incoming_message.headers[error_handler.retry_count_header] = "invalid" + expected_retry_time = datetime.now(timezone.utc) + timedelta(minutes=5) error_handler.set_next_retry_time(expected_retry_time) async def next_handler(): @@ -356,12 +386,12 @@ async def next_handler(): assert len(transaction.outgoing_messages) == 1 delayed_message = transaction.outgoing_messages[0] - assert delayed_message.headers[ErrorHandler.RETRY_COUNT_HEADER] == "1" + assert delayed_message.headers[error_handler.retry_count_header] == "1" @pytest.mark.asyncio async def test_retrier_exception_stack_trace_is_stored_in_header(self, transaction, error_handler): """Test that exception stack trace is stored in header.""" - transaction.incoming_message.headers[ErrorHandler.RETRY_COUNT_HEADER] = str(error_handler.delayed_retry_count) + transaction.incoming_message.headers[error_handler.retry_count_header] = str(error_handler.delayed_retry_count) exception_with_stack_trace = Exception("Error with stack trace") @@ -372,15 +402,18 @@ async def next_handler(): assert len(transaction.outgoing_messages) == 1 dead_letter_message = transaction.outgoing_messages[0] - assert dead_letter_message.headers[ErrorHandler.ERROR_STACK_TRACE_HEADER] is not None - assert dead_letter_message.headers[ErrorHandler.ERROR_STACK_TRACE_HEADER] != "" + assert dead_letter_message.headers[error_handler.error_stack_trace_header] is not None + assert dead_letter_message.headers[error_handler.error_stack_trace_header] != "" @pytest.mark.asyncio async def test_retrier_exception_with_null_stack_trace_uses_empty_string(self, transaction, error_handler): """Test that exception with null stack trace uses empty string.""" - transaction.incoming_message.headers[ErrorHandler.RETRY_COUNT_HEADER] = str(error_handler.delayed_retry_count) + transaction.incoming_message.headers[error_handler.retry_count_header] = str(error_handler.delayed_retry_count) - # Create an exception with null stack trace + # Note: In Python, traceback.format_exc() never returns None or empty string + # when called within an exception handler context. This test verifies the + # behavior matches the C# test structure, but the actual behavior differs. + # In Python, we'll always get some stack trace information. exception_without_stack_trace = ExceptionWithNullStackTrace("Error without stack trace") async def next_handler(): @@ -390,9 +423,9 @@ async def next_handler(): assert len(transaction.outgoing_messages) == 1 dead_letter_message = transaction.outgoing_messages[0] - # In Python, even exceptions without stack trace will have some trace info - # so we just check that the header exists - assert ErrorHandler.ERROR_STACK_TRACE_HEADER in dead_letter_message.headers + # In Python, even exceptions without explicit stack traces will have + # traceback information, so we just verify the header exists + assert error_handler.error_stack_trace_header in dead_letter_message.headers if __name__ == "__main__": diff --git a/src/python/tests/transport/transaction/message/handlers/serializers/test_json_handlers.py b/src/python/tests/transport/transaction/message/handlers/serializers/test_json_handlers.py index 981c664..bc50568 100644 --- a/src/python/tests/transport/transaction/message/handlers/serializers/test_json_handlers.py +++ b/src/python/tests/transport/transaction/message/handlers/serializers/test_json_handlers.py @@ -1,145 +1,22 @@ """Tests for JSON serialization handlers for PolyBus Python implementation.""" import pytest -import json -from typing import Any, Dict, List, Optional, Callable, Awaitable -from src.transport.transaction.message.handlers.serializers.json_handlers import JsonHandlers, InvalidOperationError -from src.transport.transaction.incoming_transaction import IncomingTransaction +from src.transport.transaction.message.handlers.serializers.json_handlers import JsonHandlers +from src.transport.transaction.message.outgoing_message import OutgoingMessage from src.transport.transaction.outgoing_transaction import OutgoingTransaction -from src.transport.transaction.message.incoming_message import IncomingMessage from src.transport.transaction.message.messages import Messages from src.transport.transaction.message.message_info import message_info from src.transport.transaction.message.message_type import MessageType from src.headers import Headers -class MockTransport: - """Mock implementation of ITransport.""" +@message_info(MessageType.COMMAND, "polybus", "json-handler-test-message", 1, 0, 0) +class JsonHandlerTestMessage: + """Test message class.""" - @property - def supports_command_messages(self) -> bool: - return True - - @property - def supports_delayed_messages(self) -> bool: - return True - - @property - def supports_subscriptions(self) -> bool: - return False - - async def send(self, transaction) -> None: - pass - - async def subscribe(self, message_info) -> None: - pass - - async def start(self) -> None: - pass - - async def stop(self) -> None: - pass - - -class MockPolyBus: - """Mock implementation of IPolyBus.""" - - def __init__(self, messages: Messages): - self._messages = messages - self._transport = MockTransport() - self._properties = {} - self._incoming_handlers = [] - self._outgoing_handlers = [] - self._name = "MockBus" - - @property - def properties(self) -> Dict[str, Any]: - return self._properties - - @property - def transport(self) -> MockTransport: - return self._transport - - @property - def incoming_handlers(self) -> List: - return self._incoming_handlers - - @property - def outgoing_handlers(self) -> List: - return self._outgoing_handlers - - @property - def messages(self) -> Messages: - return self._messages - - @property - def name(self) -> str: - return self._name - - async def create_transaction(self, message=None): - if message is None: - return MockOutgoingTransaction(self) - return MockIncomingTransaction(self, message) - - async def send(self, transaction) -> None: - pass - - async def start(self) -> None: - pass - - async def stop(self) -> None: - pass - - -class MockOutgoingTransaction(OutgoingTransaction): - """Mock outgoing transaction.""" - - def __init__(self, bus): - super().__init__(bus) - - async def abort(self) -> None: - pass - - async def commit(self) -> None: - pass - - -class MockIncomingTransaction(IncomingTransaction): - """Mock incoming transaction.""" - - def __init__(self, bus, incoming_message): - super().__init__(bus, incoming_message) - - async def abort(self) -> None: - pass - - async def commit(self) -> None: - pass - - -@message_info(MessageType.COMMAND, "test-service", "TestMessage", 1, 0, 0) -class SampleMessage: - """Sample message class.""" - - def __init__(self, id: int = 0, name: str = ""): - self.id = id - self.name = name - - def to_dict(self): - """Convert to dictionary for JSON serialization.""" - return {"id": self.id, "name": self.name} - - -class UnknownMessage: - """Message class without message_info decorator.""" - - def __init__(self, data: str = ""): - self.data = data - - def to_dict(self): - """Convert to dictionary for JSON serialization.""" - return {"data": self.data} + def __init__(self, text: str = ""): + self.text = text class TestJsonHandlers: @@ -147,435 +24,34 @@ class TestJsonHandlers: def setup_method(self): """Set up test fixtures before each test method.""" - self.json_handlers = JsonHandlers() - self.messages = Messages() - self.mock_bus = MockPolyBus(self.messages) - - # Add the test message type to the messages collection - self.messages.add(SampleMessage) - self.header = "endpoint=test-service, type=command, name=TestMessage, version=1.0.0" - - # Deserializer Tests - - @pytest.mark.asyncio - async def test_deserializer_with_valid_type_header_deserializes_message(self): - """Test that valid type header deserializes message correctly.""" - # Arrange - test_message = SampleMessage(1, "Test") - serialized_body = json.dumps({"id": test_message.id, "name": test_message.name}) - - incoming_message = IncomingMessage(self.mock_bus, serialized_body) - incoming_message.headers[Headers.MESSAGE_TYPE] = self.header - - transaction = MockIncomingTransaction(self.mock_bus, incoming_message) - - next_called = False - - async def next_handler(): - nonlocal next_called - next_called = True - - # Act - await self.json_handlers.deserializer(transaction, next_handler) - - # Assert - assert next_called is True - assert incoming_message.message is not None - assert isinstance(incoming_message.message, dict) - assert incoming_message.message["id"] == 1 - assert incoming_message.message["name"] == "Test" - assert incoming_message.message_type == SampleMessage + self._json_handlers = JsonHandlers() + self._messages = Messages() + self._messages.add(JsonHandlerTestMessage) @pytest.mark.asyncio - async def test_deserializer_with_custom_json_options_deserializes_with_options(self): - """Test that custom JSON options are used during deserialization.""" + async def test_serializer_sets_body_and_content_type(self): + """Test that serializer sets body and content type.""" # Arrange - json_options = {"parse_float": lambda x: int(float(x))} - json_handlers = JsonHandlers(json_options=json_options) + from src.poly_bus_builder import PolyBusBuilder - test_data = {"id": 2.5, "name": "Float"} - serialized_body = json.dumps(test_data) + # Create a mock bus + builder = PolyBusBuilder() + builder.messages.add(JsonHandlerTestMessage) + bus = await builder.build() - incoming_message = IncomingMessage(self.mock_bus, serialized_body) - incoming_message.headers[Headers.MESSAGE_TYPE] = self.header - - transaction = MockIncomingTransaction(self.mock_bus, incoming_message) - - next_called = False - - async def next_handler(): - nonlocal next_called - next_called = True - - # Act - await json_handlers.deserializer(transaction, next_handler) - - # Assert - assert next_called is True - assert incoming_message.message["id"] == 2 # Should be converted to int - assert incoming_message.message["name"] == "Float" - - @pytest.mark.asyncio - async def test_deserializer_with_unknown_type_and_throw_on_missing_type_false_parses_as_json(self): - """Test that unknown type with throw_on_missing_type=False parses as generic JSON.""" - # Arrange - json_handlers = JsonHandlers(throw_on_missing_type=False) - test_object = {"id": 3, "name": "Unknown"} - serialized_body = json.dumps(test_object) - header = "endpoint=test-service, type=command, name=UnknownMessage, version=1.0.0" - - incoming_message = IncomingMessage(self.mock_bus, serialized_body) - incoming_message.headers[Headers.MESSAGE_TYPE] = header - - transaction = MockIncomingTransaction(self.mock_bus, incoming_message) - - next_called = False - - async def next_handler(): - nonlocal next_called - next_called = True + message = JsonHandlerTestMessage(text="Hello, World!") + transaction = OutgoingTransaction(bus) + outgoing_message = transaction.add(message) # Act - await json_handlers.deserializer(transaction, next_handler) - - # Assert - assert next_called is True - assert incoming_message.message is not None - assert isinstance(incoming_message.message, dict) - assert incoming_message.message["id"] == 3 - assert incoming_message.message["name"] == "Unknown" - - @pytest.mark.asyncio - async def test_deserializer_with_unknown_type_and_throw_on_missing_type_true_throws_exception(self): - """Test that unknown type with throw_on_missing_type=True throws exception.""" - # Arrange - json_handlers = JsonHandlers(throw_on_missing_type=True) - test_object = {"id": 4, "name": "Error"} - serialized_body = json.dumps(test_object) - header = "endpoint=test-service, type=command, name=UnknownMessage, version=1.0.0" - - incoming_message = IncomingMessage(self.mock_bus, serialized_body) - incoming_message.headers[Headers.MESSAGE_TYPE] = header - - transaction = MockIncomingTransaction(self.mock_bus, incoming_message) - async def next_handler(): pass - # Act & Assert - with pytest.raises(InvalidOperationError) as exc_info: - await json_handlers.deserializer(transaction, next_handler) - - assert "The type header is missing, invalid, or if the type cannot be found." in str(exc_info.value) - - @pytest.mark.asyncio - async def test_deserializer_with_missing_type_header_throws_exception(self): - """Test that missing type header throws exception when throw_on_missing_type=True.""" - # Arrange - json_handlers = JsonHandlers(throw_on_missing_type=True) - incoming_message = IncomingMessage(self.mock_bus, "{}") - # No MESSAGE_TYPE header set - - transaction = MockIncomingTransaction(self.mock_bus, incoming_message) - - async def next_handler(): - pass - - # Act & Assert - with pytest.raises(InvalidOperationError) as exc_info: - await json_handlers.deserializer(transaction, next_handler) - - assert "The type header is missing, invalid, or if the type cannot be found." in str(exc_info.value) - - @pytest.mark.asyncio - async def test_deserializer_with_invalid_json_throws_json_exception(self): - """Test that invalid JSON throws JSONDecodeError.""" - # Arrange - incoming_message = IncomingMessage(self.mock_bus, "invalid json") - incoming_message.headers[Headers.MESSAGE_TYPE] = self.header - - transaction = MockIncomingTransaction(self.mock_bus, incoming_message) - - async def next_handler(): - pass - - # Act & Assert - with pytest.raises(json.JSONDecodeError): - await self.json_handlers.deserializer(transaction, next_handler) - - # Serializer Tests - - @pytest.mark.asyncio - async def test_serializer_with_valid_message_serializes_and_sets_headers(self): - """Test that valid message serializes correctly and sets headers.""" - # Arrange - test_message = {"id": 5, "name": "Serialize"} - - mock_transaction = MockOutgoingTransaction(self.mock_bus) - outgoing_message = mock_transaction.add_outgoing_message(test_message, "test-endpoint") - # Manually set the message type for the test - outgoing_message.message_type = SampleMessage - - next_called = False - - async def next_handler(): - nonlocal next_called - next_called = True - - # Act - await self.json_handlers.serializer(mock_transaction, next_handler) + await self._json_handlers.serializer(transaction, next_handler) # Assert - assert next_called is True assert outgoing_message.body is not None - - # Deserialize to verify content - deserialized_message = json.loads(outgoing_message.body) - assert deserialized_message["id"] == 5 - assert deserialized_message["name"] == "Serialize" - assert outgoing_message.headers[Headers.CONTENT_TYPE] == "application/json" - assert outgoing_message.headers[Headers.MESSAGE_TYPE] == self.header - - @pytest.mark.asyncio - async def test_serializer_with_custom_content_type_uses_custom_content_type(self): - """Test that custom content type is used.""" - # Arrange - custom_content_type = "application/custom-json" - json_handlers = JsonHandlers(content_type=custom_content_type, throw_on_invalid_type=False) - - test_message = {"id": 6, "name": "Custom"} - - mock_transaction = MockOutgoingTransaction(self.mock_bus) - outgoing_message = mock_transaction.add_outgoing_message(test_message, "test-endpoint") - # Manually set the message type for the test - outgoing_message.message_type = SampleMessage - - next_called = False - - async def next_handler(): - nonlocal next_called - next_called = True - - # Act - await json_handlers.serializer(mock_transaction, next_handler) - - # Assert - assert next_called is True - assert outgoing_message.headers[Headers.CONTENT_TYPE] == custom_content_type - - @pytest.mark.asyncio - async def test_serializer_with_custom_json_options_serializes_with_options(self): - """Test that custom JSON options are used during serialization.""" - # Arrange - json_options = {"indent": 2, "sort_keys": True} - json_handlers = JsonHandlers(json_options=json_options, throw_on_invalid_type=False) - - test_message = {"id": 7, "name": "Options"} - - mock_transaction = MockOutgoingTransaction(self.mock_bus) - outgoing_message = mock_transaction.add_outgoing_message(test_message, "test-endpoint") - # Manually set the message type for the test - outgoing_message.message_type = SampleMessage - - next_called = False - - async def next_handler(): - nonlocal next_called - next_called = True - - # Act - await json_handlers.serializer(mock_transaction, next_handler) - - # Assert - assert next_called is True - assert outgoing_message.body is not None - # Should have indentation (newlines) due to indent=2 - assert "\n" in outgoing_message.body - # Should be sorted due to sort_keys=True - lines = outgoing_message.body.split('\n') - # Find lines with keys and verify order - key_lines = [line.strip() for line in lines if ':' in line and '"' in line] - if len(key_lines) >= 2: - # Should be alphabetical order: "id" before "name" - assert "id" in key_lines[0] - assert "name" in key_lines[1] - - @pytest.mark.asyncio - async def test_serializer_with_unknown_type_and_throw_on_invalid_type_false_skips_header_setting(self): - """Test that unknown type with throw_on_invalid_type=False skips header setting.""" - # Arrange - json_handlers = JsonHandlers(throw_on_invalid_type=False) - - test_message = {"data": "test"} - - mock_transaction = MockOutgoingTransaction(self.mock_bus) - outgoing_message = mock_transaction.add_outgoing_message(test_message, "unknown-endpoint") - # Set the message type to UnknownMessage (not registered) - outgoing_message.message_type = UnknownMessage - - next_called = False - - async def next_handler(): - nonlocal next_called - next_called = True - - # Act - await json_handlers.serializer(mock_transaction, next_handler) - - # Assert - assert next_called is True - assert outgoing_message.body is not None - assert outgoing_message.headers[Headers.CONTENT_TYPE] == "application/json" - assert Headers.MESSAGE_TYPE not in outgoing_message.headers - - @pytest.mark.asyncio - async def test_serializer_with_unknown_type_and_throw_on_invalid_type_true_throws_exception(self): - """Test that unknown type with throw_on_invalid_type=True throws exception.""" - # Arrange - json_handlers = JsonHandlers(throw_on_invalid_type=True) - - test_message = {"data": "error"} - - mock_transaction = MockOutgoingTransaction(self.mock_bus) - outgoing_message = mock_transaction.add_outgoing_message(test_message, "unknown-endpoint") - # Set the message type to UnknownMessage (not registered) - outgoing_message.message_type = UnknownMessage - - async def next_handler(): - pass - - # Act & Assert - with pytest.raises(InvalidOperationError) as exc_info: - await json_handlers.serializer(mock_transaction, next_handler) - - assert "The header has an invalid type." in str(exc_info.value) - - @pytest.mark.asyncio - async def test_serializer_with_multiple_messages_serializes_all(self): - """Test that multiple messages are all serialized.""" - # Arrange - test_message1 = {"id": 8, "name": "First"} - test_message2 = {"id": 9, "name": "Second"} - - mock_transaction = MockOutgoingTransaction(self.mock_bus) - outgoing_message1 = mock_transaction.add_outgoing_message(test_message1, "test-endpoint") - outgoing_message2 = mock_transaction.add_outgoing_message(test_message2, "test-endpoint") - # Manually set the message types for the test - outgoing_message1.message_type = SampleMessage - outgoing_message2.message_type = SampleMessage - - next_called = False - - async def next_handler(): - nonlocal next_called - next_called = True - - # Act - await self.json_handlers.serializer(mock_transaction, next_handler) - - # Assert - assert next_called is True - assert outgoing_message1.body is not None - assert outgoing_message2.body is not None - - deserialized_message1 = json.loads(outgoing_message1.body) - deserialized_message2 = json.loads(outgoing_message2.body) - - assert deserialized_message1["id"] == 8 - assert deserialized_message1["name"] == "First" - assert deserialized_message2["id"] == 9 - assert deserialized_message2["name"] == "Second" - - @pytest.mark.asyncio - async def test_serializer_with_empty_outgoing_messages_calls_next(self): - """Test that empty outgoing messages still calls next handler.""" - # Arrange - mock_transaction = MockOutgoingTransaction(self.mock_bus) - - next_called = False - - async def next_handler(): - nonlocal next_called - next_called = True - - # Act - await self.json_handlers.serializer(mock_transaction, next_handler) - - # Assert - assert next_called is True - - def test_invalid_operation_error_is_exception(self): - """Test that InvalidOperationError is an exception.""" - # Act - error = InvalidOperationError("Test error") - - # Assert - assert isinstance(error, Exception) - assert str(error) == "Test error" - - def test_json_handlers_default_initialization(self): - """Test JsonHandlers default initialization.""" - # Act - handlers = JsonHandlers() - - # Assert - assert handlers.json_options == {} - assert handlers.content_type == "application/json" - assert handlers.throw_on_missing_type is True - assert handlers.throw_on_invalid_type is True - - def test_json_handlers_custom_initialization(self): - """Test JsonHandlers custom initialization.""" - # Arrange - json_options = {"indent": 4} - content_type = "application/vnd.api+json" - throw_on_missing_type = False - throw_on_invalid_type = False - - # Act - handlers = JsonHandlers( - json_options=json_options, - content_type=content_type, - throw_on_missing_type=throw_on_missing_type, - throw_on_invalid_type=throw_on_invalid_type - ) - - # Assert - assert handlers.json_options == json_options - assert handlers.content_type == content_type - assert handlers.throw_on_missing_type is False - assert handlers.throw_on_invalid_type is False - - @pytest.mark.asyncio - async def test_deserializer_with_missing_type_header_and_throw_false_parses_generic_json(self): - """Test that missing type header with throw_on_missing_type=False parses as generic JSON.""" - # Arrange - json_handlers = JsonHandlers(throw_on_missing_type=False) - test_object = {"id": 10, "name": "NoHeader"} - serialized_body = json.dumps(test_object) - - incoming_message = IncomingMessage(self.mock_bus, serialized_body) - # No MESSAGE_TYPE header set - - transaction = MockIncomingTransaction(self.mock_bus, incoming_message) - - next_called = False - - async def next_handler(): - nonlocal next_called - next_called = True - - # Act - await json_handlers.deserializer(transaction, next_handler) - - # Assert - assert next_called is True - assert incoming_message.message is not None - assert isinstance(incoming_message.message, dict) - assert incoming_message.message["id"] == 10 - assert incoming_message.message["name"] == "NoHeader" - assert incoming_message.message_type == str # Should remain as default string type if __name__ == "__main__": diff --git a/src/python/tests/transport/transaction/message/test_messages.py b/src/python/tests/transport/transaction/message/test_messages.py index fcde972..79d7a2b 100644 --- a/src/python/tests/transport/transaction/message/test_messages.py +++ b/src/python/tests/transport/transaction/message/test_messages.py @@ -5,6 +5,7 @@ from src.transport.transaction.message.messages import Messages from src.transport.transaction.message.message_info import MessageInfo, message_info from src.transport.transaction.message.message_info import MessageType +from src.transport.transaction.message.poly_bus_message_not_found_error import PolyBusMessageNotFoundError class TestMessages: @@ -15,24 +16,18 @@ def setup_method(self): self.messages = Messages() # Test Message Classes - @message_info(MessageType.COMMAND, "OrderService", "CreateOrder", 1, 0, 0) - class CreateOrderCommand: + @message_info(MessageType.COMMAND, "polybus", "polybus-command", 1, 0, 0) + class Command: def __init__(self): self.order_id: str = "" self.amount: float = 0.0 - @message_info(MessageType.EVENT, "OrderService", "OrderCreated", 2, 1, 3) - class OrderCreatedEvent: + @message_info(MessageType.EVENT, "polybus", "polybus-event", 2, 1, 3) + class Event: def __init__(self): self.order_id: str = "" self.created_at: str = "" - @message_info(MessageType.COMMAND, "PaymentService", "ProcessPayment", 1, 5, 2) - class ProcessPaymentCommand: - def __init__(self): - self.payment_id: str = "" - self.amount: float = 0.0 - class MessageWithoutAttribute: def __init__(self): self.data: str = "" @@ -40,198 +35,127 @@ def __init__(self): def test_add_valid_message_type_returns_message_info(self): """Test adding a valid message type returns MessageInfo.""" # Act - result = self.messages.add(TestMessages.CreateOrderCommand) + result = self.messages.add(TestMessages.Command) # Assert assert result is not None assert result.message_type == MessageType.COMMAND - assert result.endpoint == "OrderService" - assert result.name == "CreateOrder" + assert result.endpoint == "polybus" + assert result.name == "polybus-command" assert result.major == 1 assert result.minor == 0 assert result.patch == 0 - def test_add_message_type_without_attribute_raises_value_error(self): - """Test adding a message type without attribute raises ValueError.""" + def test_add_message_type_without_attribute_throws_error(self): + """Test adding a message type without attribute throws error.""" # Act & Assert - with pytest.raises(ValueError) as excinfo: + with pytest.raises(PolyBusMessageNotFoundError): self.messages.add(TestMessages.MessageWithoutAttribute) - - assert "does not have a MessageInfo decorator" in str(excinfo.value) - assert TestMessages.MessageWithoutAttribute.__name__ in str(excinfo.value) def test_get_message_info_existing_type_returns_correct_message_info(self): """Test getting message info for existing type returns correct info.""" # Arrange - self.messages.add(TestMessages.CreateOrderCommand) + self.messages.add(TestMessages.Command) # Act - result = self.messages.get_message_info(TestMessages.CreateOrderCommand) + result = self.messages.get_message_info(TestMessages.Command) # Assert assert result is not None assert result.message_type == MessageType.COMMAND - assert result.endpoint == "OrderService" - assert result.name == "CreateOrder" - - def test_get_message_info_non_existent_type_returns_none(self): - """Test getting message info for non-existent type returns None.""" - # Act - result = self.messages.get_message_info(TestMessages.CreateOrderCommand) - - # Assert - assert result is None - - def test_get_header_existing_type_returns_correct_header(self): - """Test getting header for existing type returns correct header.""" - # Arrange - self.messages.add(TestMessages.OrderCreatedEvent) - - # Act - result = self.messages.get_header(TestMessages.OrderCreatedEvent) - - # Assert - assert result == "endpoint=OrderService, type=event, name=OrderCreated, version=2.1.3" - - def test_get_header_non_existent_type_returns_none(self): - """Test getting header for non-existent type returns None.""" - # Act - result = self.messages.get_header(TestMessages.CreateOrderCommand) - - # Assert - assert result is None - - def test_get_type_by_header_valid_header_returns_correct_type(self): - """Test getting type by valid header returns correct type.""" - # Arrange - self.messages.add(TestMessages.ProcessPaymentCommand) - header = "endpoint=PaymentService, type=command, name=ProcessPayment, version=1.5.2" - - # Act - result = self.messages.get_type_by_header(header) - - # Assert - assert result == TestMessages.ProcessPaymentCommand - - def test_get_type_by_header_invalid_header_returns_none(self): - """Test getting type by invalid header returns None.""" - # Arrange - invalid_header = "invalid header format" - - # Act - result = self.messages.get_type_by_header(invalid_header) - - # Assert - assert result is None + assert result.endpoint == "polybus" + assert result.name == "polybus-command" - def test_get_type_by_header_non_existent_message_returns_none(self): - """Test getting type by header for non-existent message returns None.""" - # Arrange - header = "endpoint=UnknownService, type=command, name=UnknownCommand, version=1.0.0" - - # Act - result = self.messages.get_type_by_header(header) - - # Assert - assert result is None - - def test_get_type_by_header_caches_results(self): - """Test that get_type_by_header caches results.""" - # Arrange - self.messages.add(TestMessages.CreateOrderCommand) - header = "endpoint=OrderService, type=command, name=CreateOrder, version=1.0.0" - - # Act - result1 = self.messages.get_type_by_header(header) - result2 = self.messages.get_type_by_header(header) - - # Assert - assert result1 == TestMessages.CreateOrderCommand - assert result2 == TestMessages.CreateOrderCommand - assert result1 is result2 + def test_get_message_info_non_existent_type_throws_error(self): + """Test getting message info for non-existent type throws error.""" + # Act & Assert + with pytest.raises(PolyBusMessageNotFoundError): + self.messages.get_message_info(TestMessages.Command) def test_get_type_by_message_info_existing_message_info_returns_correct_type(self): """Test getting type by existing MessageInfo returns correct type.""" # Arrange - self.messages.add(TestMessages.OrderCreatedEvent) - message_info = MessageInfo(MessageType.EVENT, "OrderService", "OrderCreated", 2, 1, 3) + self.messages.add(TestMessages.Event) + message_info_obj = MessageInfo(MessageType.EVENT, "polybus", "polybus-event", 2, 1, 3) # Act - result = self.messages.get_type_by_message_info(message_info) + result = self.messages.get_type_by_message_info(message_info_obj) # Assert - assert result == TestMessages.OrderCreatedEvent + assert result == TestMessages.Event - def test_get_type_by_message_info_non_existent_message_info_returns_none(self): - """Test getting type by non-existent MessageInfo returns None.""" + def test_get_type_by_message_info_non_existent_message_info_throws_error(self): + """Test getting type by non-existent MessageInfo throws error.""" # Arrange - message_info = MessageInfo(MessageType.COMMAND, "UnknownService", "UnknownCommand", 1, 0, 0) + message_info_obj = MessageInfo(MessageType.COMMAND, "unknown", "unknown-command", 1, 0, 0) - # Act - result = self.messages.get_type_by_message_info(message_info) - - # Assert - assert result is None + # Act & Assert + with pytest.raises(PolyBusMessageNotFoundError): + self.messages.get_type_by_message_info(message_info_obj) def test_get_type_by_message_info_different_minor_patch_versions_returns_type(self): """Test getting type by MessageInfo with different minor/patch versions returns type.""" # Arrange - self.messages.add(TestMessages.OrderCreatedEvent) # Has version 2.1.3 - message_info_different_minor = MessageInfo(MessageType.EVENT, "OrderService", "OrderCreated", 2, 5, 3) - message_info_different_patch = MessageInfo(MessageType.EVENT, "OrderService", "OrderCreated", 2, 1, 9) + self.messages.add(TestMessages.Event) # Has version 2.1.3 + message_info_different_minor = MessageInfo(MessageType.EVENT, "polybus", "polybus-event", 2, 5, 3) + message_info_different_patch = MessageInfo(MessageType.EVENT, "polybus", "polybus-event", 2, 1, 9) # Act result1 = self.messages.get_type_by_message_info(message_info_different_minor) result2 = self.messages.get_type_by_message_info(message_info_different_patch) # Assert - assert result1 == TestMessages.OrderCreatedEvent - assert result2 == TestMessages.OrderCreatedEvent + assert result1 == TestMessages.Event + assert result2 == TestMessages.Event - def test_get_type_by_message_info_different_major_version_returns_none(self): - """Test getting type by MessageInfo with different major version returns None.""" + def test_get_type_by_message_info_different_major_version_throws_error(self): + """Test getting type by MessageInfo with different major version throws error.""" # Arrange - self.messages.add(TestMessages.OrderCreatedEvent) # Has version 2.1.3 - message_info_different_major = MessageInfo(MessageType.EVENT, "OrderService", "OrderCreated", 3, 1, 3) - - # Act - result = self.messages.get_type_by_message_info(message_info_different_major) + self.messages.add(TestMessages.Event) # Has version 2.1.3 + message_info_different_major = MessageInfo(MessageType.EVENT, "polybus", "polybus-event", 3, 1, 3) - # Assert - assert result is None + # Act & Assert + with pytest.raises(PolyBusMessageNotFoundError): + self.messages.get_type_by_message_info(message_info_different_major) - def test_multiple_messages_all_methods_work_correctly(self): - """Test that all methods work correctly with multiple messages.""" + def test_add_same_type_twice_throws_error(self): + """Test adding the same type twice throws error.""" # Arrange - self.messages.add(TestMessages.CreateOrderCommand) - self.messages.add(TestMessages.OrderCreatedEvent) - self.messages.add(TestMessages.ProcessPaymentCommand) - - # Act & Assert - get_message_info - command_info = self.messages.get_message_info(TestMessages.CreateOrderCommand) - event_info = self.messages.get_message_info(TestMessages.OrderCreatedEvent) - payment_info = self.messages.get_message_info(TestMessages.ProcessPaymentCommand) + self.messages.add(TestMessages.Command) - assert command_info.message_type == MessageType.COMMAND - assert event_info.message_type == MessageType.EVENT - assert payment_info.endpoint == "PaymentService" + # Act & Assert + with pytest.raises(PolyBusMessageNotFoundError): + self.messages.add(TestMessages.Command) + + def test_get_header_by_message_info_existing_message_info_returns_correct_header(self): + """Test getting header by existing MessageInfo returns correct header.""" + # Arrange + self.messages.add(TestMessages.Command) + message_info_obj = MessageInfo(MessageType.COMMAND, "polybus", "polybus-command", 1, 0, 0) - # Act & Assert - get_header - command_header = self.messages.get_header(TestMessages.CreateOrderCommand) - event_header = self.messages.get_header(TestMessages.OrderCreatedEvent) + # Act + result = self.messages.get_header_by_message_info(message_info_obj) - assert "OrderService" in command_header - assert "OrderCreated" in event_header + # Assert + assert result is not None + assert result != "" + assert result == message_info_obj.to_string(True) + + def test_get_header_by_message_info_non_existent_message_info_throws_error(self): + """Test getting header by non-existent MessageInfo throws error.""" + # Arrange + message_info_obj = MessageInfo(MessageType.COMMAND, "unknown", "unknown-command", 1, 0, 0) - # Act & Assert - get_type_by_header - type_from_header = self.messages.get_type_by_header(command_header) - assert type_from_header == TestMessages.CreateOrderCommand + # Act & Assert + with pytest.raises(PolyBusMessageNotFoundError): + self.messages.get_header_by_message_info(message_info_obj) - def test_add_same_type_twice_raises_key_error(self): - """Test adding the same type twice raises KeyError.""" + def test_get_header_by_message_info_different_major_version_throws_error(self): + """Test getting header by MessageInfo with different major version throws error.""" # Arrange - self.messages.add(TestMessages.CreateOrderCommand) + self.messages.add(TestMessages.Event) # Has version 2.1.3 + message_info_different_major = MessageInfo(MessageType.EVENT, "polybus", "polybus-event", 3, 1, 3) # Act & Assert - with pytest.raises(KeyError): - self.messages.add(TestMessages.CreateOrderCommand) \ No newline at end of file + with pytest.raises(PolyBusMessageNotFoundError): + self.messages.get_header_by_message_info(message_info_different_major) From 74c2e90ecbcdfb10a846ab53d7b6239997a22c77 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Sun, 30 Nov 2025 12:49:41 -0500 Subject: [PATCH 4/5] refactor: typescript implementation --- src/typescript/README.md | 29 - src/typescript/jest.config.js | 3 +- src/typescript/src/__tests__/poly-bus.test.ts | 203 ------ src/typescript/src/i-poly-bus.ts | 20 +- src/typescript/src/index.ts | 16 +- src/typescript/src/poly-bus-builder.ts | 81 +-- src/typescript/src/poly-bus-error.ts | 29 + src/typescript/src/poly-bus.ts | 40 +- src/typescript/src/transport/i-transport.ts | 20 +- .../in-memory/__tests__/alpha-command.ts | 10 + .../in-memory/__tests__/alpha-event.ts | 10 + .../__tests__/in-memory-transport.test.ts | 220 +++++++ .../in-memory/__tests__/test-endpoint.ts | 22 + .../in-memory/__tests__/test-environment.ts | 53 ++ .../transport/in-memory/in-memory-endpoint.ts | 91 +++ .../in-memory/in-memory-message-broker.ts | 124 ++++ .../in-memory/in-memory-transport.ts | 146 ----- .../transport/poly-bus-not-started-error.ts | 19 + ...ory.ts => incoming-transaction-factory.ts} | 4 +- .../message/__tests__/messages.test.ts | 222 ++----- .../error/__tests__/error-handlers.test.ts | 600 ++++++++---------- .../message/handlers/error/error-handlers.ts | 93 +-- .../__tests__/json-handlers.test.ts | 508 ++------------- .../handlers/serializers/json-handlers.ts | 63 +- .../transaction/message/incoming-message.ts | 27 +- .../transport/transaction/message/message.ts | 14 +- .../transport/transaction/message/messages.ts | 86 +-- .../transaction/message/outgoing-message.ts | 36 +- .../poly-bus-message-not-found-error.ts | 19 + .../outgoing-transaction-factory.ts | 10 + .../src/transport/transaction/transaction.ts | 15 +- 31 files changed, 1230 insertions(+), 1603 deletions(-) delete mode 100644 src/typescript/src/__tests__/poly-bus.test.ts create mode 100644 src/typescript/src/poly-bus-error.ts create mode 100644 src/typescript/src/transport/in-memory/__tests__/alpha-command.ts create mode 100644 src/typescript/src/transport/in-memory/__tests__/alpha-event.ts create mode 100644 src/typescript/src/transport/in-memory/__tests__/in-memory-transport.test.ts create mode 100644 src/typescript/src/transport/in-memory/__tests__/test-endpoint.ts create mode 100644 src/typescript/src/transport/in-memory/__tests__/test-environment.ts create mode 100644 src/typescript/src/transport/in-memory/in-memory-endpoint.ts create mode 100644 src/typescript/src/transport/in-memory/in-memory-message-broker.ts delete mode 100644 src/typescript/src/transport/in-memory/in-memory-transport.ts create mode 100644 src/typescript/src/transport/poly-bus-not-started-error.ts rename src/typescript/src/transport/transaction/{transaction-factory.ts => incoming-transaction-factory.ts} (65%) create mode 100644 src/typescript/src/transport/transaction/message/poly-bus-message-not-found-error.ts create mode 100644 src/typescript/src/transport/transaction/outgoing-transaction-factory.ts diff --git a/src/typescript/README.md b/src/typescript/README.md index 21ff7bd..3400c7d 100644 --- a/src/typescript/README.md +++ b/src/typescript/README.md @@ -8,35 +8,6 @@ A TypeScript implementation of the PolyBus messaging library, providing a unifie - npm or yarn package manager - Any IDE that supports TypeScript development (VS Code, WebStorm, etc.) -## Project Structure - -``` -src/typescript/ -├── src/ # Source code -│ ├── index.ts # Main entry point -│ ├── i-poly-bus.ts # Main interface -│ ├── poly-bus.ts # Core implementation -│ ├── poly-bus-builder.ts # Builder pattern implementation -│ ├── headers.ts # Message headers -│ ├── transport/ # Transport implementations -│ │ ├── i-transport.ts # Transport interface -│ │ └── ... -│ └── __tests__/ # Test files -│ ├── poly-bus.test.ts # Test implementations -│ └── headers.test.ts -├── dist/ # Compiled output -│ ├── index.js # CommonJS build -│ ├── index.mjs # ES Module build -│ ├── index.umd.js # UMD build (browser) -│ └── index.d.ts # TypeScript declarations -├── package.json # Project configuration and dependencies -├── tsconfig.json # TypeScript configuration -├── jest.config.js # Jest testing configuration -├── eslint.config.js # ESLint configuration -├── rollup.config.js # Rollup bundler configuration -└── README.md # This file -``` - ## Quick Start ### Installing Dependencies diff --git a/src/typescript/jest.config.js b/src/typescript/jest.config.js index 0aaafae..521def1 100644 --- a/src/typescript/jest.config.js +++ b/src/typescript/jest.config.js @@ -6,7 +6,8 @@ export default { // Test file patterns testMatch: [ - '/src/**/__tests__/**/*.ts', + '/src/**/__tests__/**/*.test.ts', + '/src/**/__tests__/**/*.spec.ts', '/src/**/?(*.)+(spec|test).ts' ], diff --git a/src/typescript/src/__tests__/poly-bus.test.ts b/src/typescript/src/__tests__/poly-bus.test.ts deleted file mode 100644 index 43ff32f..0000000 --- a/src/typescript/src/__tests__/poly-bus.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, it, expect, beforeEach } from '@jest/globals'; -import { PolyBusBuilder } from '../poly-bus-builder'; -import { IncomingTransaction } from '../transport/transaction/incoming-transaction'; -import { OutgoingTransaction } from '../transport/transaction/outgoing-transaction'; -import { InMemoryTransport } from '../transport/in-memory/in-memory-transport'; - -describe('PolyBus', () => { - let inMemoryTransport: InMemoryTransport; - - beforeEach(() => { - inMemoryTransport = new InMemoryTransport(); - }); - - describe('IncomingHandlers', () => { - it('should invoke incoming handlers', async () => { - // Arrange - const incomingTransactionPromise = new Promise((resolve) => { - const builder = new PolyBusBuilder(); - builder.incomingHandlers.push(async (transaction, next) => { - await next(); - resolve(transaction); - }); - builder.transportFactory = async (builder, bus) => inMemoryTransport.addEndpoint(builder, bus); - - builder.build().then(async (bus) => { - // Act - await bus.start(); - const outgoingTransaction = await bus.createTransaction(); - outgoingTransaction.addOutgoingMessage('Hello world', 'unknown-endpoint'); - await outgoingTransaction.commit(); - - // Allow processing to complete - // eslint-disable-next-line no-undef - await new Promise(resolve => setTimeout(resolve, 10)); - await bus.stop(); - }); - }); - - const transaction = await incomingTransactionPromise; - - // Assert - expect(transaction).toBeDefined(); - expect(transaction.incomingMessage.body).toBe('Hello world'); - }); - - it('should invoke incoming handlers even when exception is thrown', async () => { - // Arrange - const incomingTransactionPromise = new Promise((resolve) => { - const builder = new PolyBusBuilder(); - builder.incomingHandlers.push(async (transaction) => { - resolve(transaction); - throw new Error(transaction.incomingMessage.body); - }); - builder.transportFactory = async (builder, bus) => inMemoryTransport.addEndpoint(builder, bus); - - builder.build().then(async (bus) => { - // Act - await bus.start(); - const outgoingTransaction = await bus.createTransaction(); - outgoingTransaction.addOutgoingMessage('Hello world', 'unknown-endpoint'); - await outgoingTransaction.commit(); - - // Allow processing to complete - // eslint-disable-next-line no-undef - await new Promise(resolve => setTimeout(resolve, 10)); - await bus.stop(); - }); - }); - - const transaction = await incomingTransactionPromise; - - // Assert - expect(transaction).toBeDefined(); - expect(transaction.incomingMessage.body).toBe('Hello world'); - }); - - it('should invoke incoming handlers with delay', async () => { - // Arrange - const processedOnPromise = new Promise((resolve) => { - const builder = new PolyBusBuilder(); - builder.incomingHandlers.push(async (_, next) => { - await next(); - resolve(new Date()); - }); - builder.transportFactory = async (builder, bus) => inMemoryTransport.addEndpoint(builder, bus); - - builder.build().then(async (bus) => { - // Act - await bus.start(); - const outgoingTransaction = await bus.createTransaction(); - const message = outgoingTransaction.addOutgoingMessage('Hello world', 'unknown-endpoint'); - const scheduledAt = new Date(Date.now() + 1000); // 1 second from now - message.deliverAt = scheduledAt; - await outgoingTransaction.commit(); - - // Allow processing to complete - // eslint-disable-next-line no-undef - setTimeout(async () => { - await bus.stop(); - }, 2000); // Stop after 2 seconds - }); - }); - - const processedOn = await processedOnPromise; - - // Assert - expect(processedOn).toBeDefined(); - const now = new Date(); - const timeDiff = processedOn.getTime() - (now.getTime() - 2000); // Approximately when it was scheduled - expect(timeDiff).toBeGreaterThanOrEqual(800); // Allow some margin for timing - }, 5000); // 5 second timeout for this test - - it('should invoke incoming handlers with delay and exception', async () => { - // Arrange - const processedOnPromise = new Promise((resolve) => { - const builder = new PolyBusBuilder(); - builder.incomingHandlers.push(async (transaction) => { - resolve(new Date()); - throw new Error(transaction.incomingMessage.body); - }); - builder.transportFactory = async (builder, bus) => inMemoryTransport.addEndpoint(builder, bus); - - builder.build().then(async (bus) => { - // Act - await bus.start(); - const outgoingTransaction = await bus.createTransaction(); - const message = outgoingTransaction.addOutgoingMessage('Hello world', 'unknown-endpoint'); - const scheduledAt = new Date(Date.now() + 1000); // 1 second from now - message.deliverAt = scheduledAt; - await outgoingTransaction.commit(); - - // Allow processing to complete - // eslint-disable-next-line no-undef - setTimeout(async () => { - await bus.stop(); - }, 2000); // Stop after 2 seconds - }); - }); - - const processedOn = await processedOnPromise; - - // Assert - expect(processedOn).toBeDefined(); - const now = new Date(); - const timeDiff = processedOn.getTime() - (now.getTime() - 2000); // Approximately when it was scheduled - expect(timeDiff).toBeGreaterThanOrEqual(800); // Allow some margin for timing - }, 5000); // 5 second timeout for this test - }); - - describe('OutgoingHandlers', () => { - it('should invoke outgoing handlers', async () => { - // Arrange - const outgoingTransactionPromise = new Promise((resolve) => { - const builder = new PolyBusBuilder(); - builder.outgoingHandlers.push(async (transaction, next) => { - await next(); - resolve(transaction); - }); - builder.transportFactory = async (builder, bus) => inMemoryTransport.addEndpoint(builder, bus); - - builder.build().then(async (bus) => { - // Act - await bus.start(); - const outgoingTransaction = await bus.createTransaction(); - outgoingTransaction.addOutgoingMessage('Hello world', 'unknown-endpoint'); - await outgoingTransaction.commit(); - - // Allow processing to complete - // eslint-disable-next-line no-undef - await new Promise(resolve => setTimeout(resolve, 10)); - await bus.stop(); - }); - }); - - const transaction = await outgoingTransactionPromise; - - // Assert - expect(transaction).toBeDefined(); - expect(transaction.outgoingMessages).toHaveLength(1); - expect(transaction.outgoingMessages[0].body).toBe('Hello world'); - }); - - it('should invoke outgoing handlers and handle exceptions', async () => { - // Arrange - const builder = new PolyBusBuilder(); - builder.outgoingHandlers.push(async (transaction) => { - throw new Error(transaction.outgoingMessages[0].body); - }); - builder.transportFactory = async (builder, bus) => inMemoryTransport.addEndpoint(builder, bus); - - const bus = await builder.build(); - - // Act & Assert - await bus.start(); - const outgoingTransaction = await bus.createTransaction(); - outgoingTransaction.addOutgoingMessage('Hello world', 'unknown-endpoint'); - - await expect(outgoingTransaction.commit()).rejects.toThrow('Hello world'); - - await bus.stop(); - }); - }); -}); diff --git a/src/typescript/src/i-poly-bus.ts b/src/typescript/src/i-poly-bus.ts index c05aab1..142c987 100644 --- a/src/typescript/src/i-poly-bus.ts +++ b/src/typescript/src/i-poly-bus.ts @@ -4,6 +4,8 @@ import { ITransport } from './transport/i-transport'; import { Messages } from './transport/transaction/message/messages'; import { OutgoingHandler } from './transport/transaction/message/handlers/outgoing-handler'; import { Transaction } from './transport/transaction/transaction'; +import { IncomingTransaction } from './transport/transaction/incoming-transaction'; +import { OutgoingTransaction } from './transport/transaction/outgoing-transaction'; export interface IPolyBus { /** @@ -19,12 +21,12 @@ export interface IPolyBus { /** * Collection of handlers for processing incoming messages. */ - incomingHandlers: IncomingHandler[]; + incomingPipeline: IncomingHandler[]; /** * Collection of handlers for processing outgoing messages. */ - outgoingHandlers: OutgoingHandler[]; + outgoingPipeline: OutgoingHandler[]; /** * Collection of message types and their associated headers. @@ -32,11 +34,17 @@ export interface IPolyBus { messages: Messages; /** - * Creates a new transaction, optionally based on an incoming message. - * @param message Optional incoming message to create the transaction from. - * @returns A promise that resolves to the created transaction. + * Creates a new incoming transaction. + * @param incoming message to create the transaction from. + * @returns A promise that resolves to the created incoming transaction. */ - createTransaction(message?: IncomingMessage): Promise; + createIncomingTransaction(message: IncomingMessage): Promise; + + /** + * Creates a new outgoing transaction. + * @returns A promise that resolves to the created outgoing transaction. + */ + createOutgoingTransaction(): Promise; /** * Sends messages associated with the given transaction to the transport. diff --git a/src/typescript/src/index.ts b/src/typescript/src/index.ts index af4088c..c827e5d 100644 --- a/src/typescript/src/index.ts +++ b/src/typescript/src/index.ts @@ -11,18 +11,22 @@ export { Headers } from './headers'; export { IncomingHandler } from './transport/transaction/message/handlers/incoming-handler'; export { IncomingMessage } from './transport/transaction/message/incoming-message'; export { IncomingTransaction } from './transport/transaction/incoming-transaction'; -export { InMemoryTransport } from './transport/in-memory/in-memory-transport'; -export { ITransport } from './transport/i-transport'; +export { IncomingTransactionFactory } from './transport/transaction/incoming-transaction-factory'; +export { InMemoryEndpoint } from './transport/in-memory/in-memory-endpoint'; +export { InMemoryMessageBroker } from './transport/in-memory/in-memory-message-broker'; export { IPolyBus } from './i-poly-bus'; +export { ITransport } from './transport/i-transport'; export { JsonHandlers } from './transport/transaction/message/handlers/serializers/json-handlers'; export { Message } from './transport/transaction/message/message'; -export { MessageInfo, messageInfo, MessageType } from './transport/transaction/message/message-info'; +export { MessageInfo, MessageInfoMetadata, messageInfo, MessageType } from './transport/transaction/message/message-info'; export { Messages } from './transport/transaction/message/messages'; export { OutgoingHandler } from './transport/transaction/message/handlers/outgoing-handler'; export { OutgoingMessage } from './transport/transaction/message/outgoing-message'; export { OutgoingTransaction } from './transport/transaction/outgoing-transaction'; +export { OutgoingTransactionFactory } from './transport/transaction/outgoing-transaction-factory'; export { PolyBus } from './poly-bus'; -export { PolyBusBuilder } from './poly-bus-builder'; +export { PolyBusBuilder, TransportFactory } from './poly-bus-builder'; +export { PolyBusError } from './poly-bus-error'; +export { PolyBusMessageNotFoundError } from './transport/transaction/message/poly-bus-message-not-found-error'; +export { PolyBusNotStartedError } from './transport/poly-bus-not-started-error'; export { Transaction } from './transport/transaction/transaction'; -export { TransactionFactory } from './transport/transaction/transaction-factory'; -export { TransportFactory } from './poly-bus-builder'; diff --git a/src/typescript/src/poly-bus-builder.ts b/src/typescript/src/poly-bus-builder.ts index 94bef34..974a28c 100644 --- a/src/typescript/src/poly-bus-builder.ts +++ b/src/typescript/src/poly-bus-builder.ts @@ -4,10 +4,12 @@ import { ITransport } from './transport/i-transport'; import { IncomingHandler } from './transport/transaction/message/handlers/incoming-handler'; import { OutgoingHandler } from './transport/transaction/message/handlers/outgoing-handler'; import { Messages } from './transport/transaction/message/messages'; -import { TransactionFactory } from './transport/transaction/transaction-factory'; +import { IncomingTransactionFactory } from './transport/transaction/incoming-transaction-factory'; +import { OutgoingTransactionFactory } from './transport/transaction/outgoing-transaction-factory'; import { IncomingTransaction } from './transport/transaction/incoming-transaction'; import { OutgoingTransaction } from './transport/transaction/outgoing-transaction'; import { PolyBus } from './poly-bus'; +import { InMemoryMessageBroker } from './transport/in-memory/in-memory-message-broker'; /** * A factory method for creating the transport for the PolyBus instance. @@ -19,94 +21,53 @@ export type TransportFactory = (builder: PolyBusBuilder, bus: IPolyBus) => Promi * Builder class for configuring and creating PolyBus instances. */ export class PolyBusBuilder { - private _transactionFactory: TransactionFactory; - private _transportFactory: TransportFactory; - private readonly _incomingHandlers: IncomingHandler[] = []; - private readonly _outgoingHandlers: OutgoingHandler[] = []; - private readonly _messages: Messages = new Messages(); - private _name: string = 'PolyBusInstance'; - /** - * Creates a new PolyBusBuilder instance. + * The incoming transaction factory will be used to create incoming transactions for handling messages. */ - constructor() { - // Default transaction factory - creates IncomingTransaction for incoming messages, - // OutgoingTransaction for outgoing messages - this._transactionFactory = (_, bus, message) => { - return Promise.resolve( - message != null - ? new IncomingTransaction(bus, message) - : new OutgoingTransaction(bus) - ); - }; - - // Default transport factory - should be overridden by specific transport implementations - this._transportFactory = async (_builder, _bus) => { - throw new Error('Transport factory must be configured before building PolyBus. Use a transport-specific builder method.'); - }; - } + public incomingTransactionFactory: IncomingTransactionFactory = (_, bus, message) => { + return Promise.resolve(new IncomingTransaction(bus, message)); + }; /** - * The transaction factory will be used to create transactions for message handling. - * Transactions are used to ensure that a group of messages related to a single request - * are sent to the transport in a single atomic operation. + * The outgoing transaction factory will be used to create outgoing transactions for sending messages. */ - public get transactionFactory(): TransactionFactory { - return this._transactionFactory; - } - - public set transactionFactory(value: TransactionFactory) { - this._transactionFactory = value; - } + public outgoingTransactionFactory: OutgoingTransactionFactory = (_, bus) => { + return Promise.resolve(new OutgoingTransaction(bus)); + }; /** * The transport factory will be used to create the transport for the PolyBus instance. * The transport is responsible for sending and receiving messages. */ - public get transportFactory(): TransportFactory { - return this._transportFactory; - } - - public set transportFactory(value: TransportFactory) { - this._transportFactory = value; - } + public transportFactory: TransportFactory = async (builder, bus) => { + const transport = new InMemoryMessageBroker(); + return transport.addEndpoint(builder, bus); + }; /** * The properties associated with this bus instance. */ - properties: Map = new Map(); + public properties: Map = new Map(); /** * Collection of handlers for processing incoming messages. */ - public get incomingHandlers(): IncomingHandler[] { - return this._incomingHandlers; - } + public incomingPipeline: IncomingHandler[] = []; /** * Collection of handlers for processing outgoing messages. */ - public get outgoingHandlers(): OutgoingHandler[] { - return this._outgoingHandlers; - } + public outgoingPipeline: OutgoingHandler[] = []; /** * Collection of message types and their associated headers. */ - public get messages(): Messages { - return this._messages; - } + public messages: Messages = new Messages(); /** * The name of this bus instance. */ - public get name(): string { - return this._name; - } - - public set name(value: string) { - this._name = value; - } + public name: string = 'polybus'; /** * Builds and configures a new PolyBus instance. @@ -114,7 +75,7 @@ export class PolyBusBuilder { */ public async build(): Promise { const bus = new PolyBus(this); - bus.transport = await this._transportFactory(this, bus); + bus.transport = await this.transportFactory(this, bus); return bus; } } diff --git a/src/typescript/src/poly-bus-error.ts b/src/typescript/src/poly-bus-error.ts new file mode 100644 index 0000000..c5df9bb --- /dev/null +++ b/src/typescript/src/poly-bus-error.ts @@ -0,0 +1,29 @@ +/** + * Base error class for PolyBus errors. + */ +export class PolyBusError extends Error { + private readonly _errorCode: number; + + /** + * Creates a new PolyBusError instance. + * @param errorCode The error code associated with this error. + * @param message The error message. + */ + constructor(errorCode: number, message: string) { + super(message); + this._errorCode = errorCode; + this.name = 'PolyBusError'; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, PolyBusError); + } + } + + /** + * Gets the error code associated with this error. + */ + public get errorCode(): number { + return this._errorCode; + } +} diff --git a/src/typescript/src/poly-bus.ts b/src/typescript/src/poly-bus.ts index 6ae72d2..f7559f5 100644 --- a/src/typescript/src/poly-bus.ts +++ b/src/typescript/src/poly-bus.ts @@ -14,8 +14,8 @@ import { PolyBusBuilder } from './poly-bus-builder'; */ export class PolyBus implements IPolyBus { private _transport!: ITransport; - private readonly _incomingHandlers: IncomingHandler[]; - private readonly _outgoingHandlers: OutgoingHandler[]; + private readonly _incomingPipeline: IncomingHandler[]; + private readonly _outgoingPipeline: OutgoingHandler[]; private readonly _messages: Messages; private readonly _name: string; private readonly _builder: PolyBusBuilder; @@ -26,8 +26,8 @@ export class PolyBus implements IPolyBus { */ constructor(builder: PolyBusBuilder) { this._builder = builder; - this._incomingHandlers = builder.incomingHandlers; - this._outgoingHandlers = builder.outgoingHandlers; + this._incomingPipeline = builder.incomingPipeline; + this._outgoingPipeline = builder.outgoingPipeline; this._messages = builder.messages; this._name = builder.name; } @@ -53,15 +53,15 @@ export class PolyBus implements IPolyBus { /** * Collection of handlers for processing incoming messages. */ - public get incomingHandlers(): IncomingHandler[] { - return this._incomingHandlers; + public get incomingPipeline(): IncomingHandler[] { + return this._incomingPipeline; } /** * Collection of handlers for processing outgoing messages. */ - public get outgoingHandlers(): OutgoingHandler[] { - return this._outgoingHandlers; + public get outgoingPipeline(): OutgoingHandler[] { + return this._outgoingPipeline; } /** @@ -79,12 +79,20 @@ export class PolyBus implements IPolyBus { } /** - * Creates a new transaction, optionally based on an incoming message. - * @param message Optional incoming message to create the transaction from. - * @returns A promise that resolves to the created transaction. + * Creates a new incoming transaction. + * @param message Incoming message to create the transaction from. + * @returns A promise that resolves to the created incoming transaction. */ - public async createTransaction(message?: IncomingMessage): Promise { - return this._builder.transactionFactory(this._builder, this, message); + public async createIncomingTransaction(message: IncomingMessage): Promise { + return this._builder.incomingTransactionFactory(this._builder, this, message); + } + + /** + * Creates a new outgoing transaction. + * @returns A promise that resolves to the created outgoing transaction. + */ + public async createOutgoingTransaction(): Promise { + return this._builder.outgoingTransactionFactory(this._builder, this); } /** @@ -94,17 +102,17 @@ export class PolyBus implements IPolyBus { * @returns A promise that resolves when the messages have been sent. */ public async send(transaction: Transaction): Promise { - let step = () => this.transport.send(transaction); + let step = () => this.transport.handle(transaction); if (transaction instanceof IncomingTransaction) { - const handlers = transaction.bus.incomingHandlers; + const handlers = transaction.bus.incomingPipeline; for (let index = handlers.length - 1; index >= 0; index--) { const handler = handlers[index]; const next = step; step = () => handler(transaction, next); } } else if (transaction instanceof OutgoingTransaction) { - const handlers = transaction.bus.outgoingHandlers; + const handlers = transaction.bus.outgoingPipeline; for (let index = handlers.length - 1; index >= 0; index--) { const handler = handlers[index]; const next = step; diff --git a/src/typescript/src/transport/i-transport.ts b/src/typescript/src/transport/i-transport.ts index 2765093..39233cb 100644 --- a/src/typescript/src/transport/i-transport.ts +++ b/src/typescript/src/transport/i-transport.ts @@ -6,17 +6,31 @@ import { Transaction } from './transaction/transaction'; */ export interface ITransport { + /** + * Where messages that cannot be delivered are sent. + */ + deadLetterEndpoint: string; + + /** + * If the transport supports sending command messages, this will be true. + */ supportsCommandMessages: boolean; - supportsDelayedMessages: boolean; + /** + * If the transport supports sending delayed commands, this will be true. + */ + supportsDelayedCommands: boolean; + /** + * If the transport supports event message subscriptions, this will be true. + */ supportsSubscriptions: boolean; /** * Sends messages associated with the given transaction to the transport. */ // eslint-disable-next-line no-unused-vars - send(transaction: Transaction): Promise; + handle(transaction: Transaction): Promise; /** * Subscribes to a messages so that the transport can start receiving them. @@ -25,7 +39,7 @@ export interface ITransport { subscribe(messageInfo: MessageInfo): Promise; /** - * Enables the transport to start processing messages. + * Starts the transport to start processing messages. */ start(): Promise; diff --git a/src/typescript/src/transport/in-memory/__tests__/alpha-command.ts b/src/typescript/src/transport/in-memory/__tests__/alpha-command.ts new file mode 100644 index 0000000..4fa6901 --- /dev/null +++ b/src/typescript/src/transport/in-memory/__tests__/alpha-command.ts @@ -0,0 +1,10 @@ +import { messageInfo } from '../../transaction/message/message-info'; +import { MessageType } from '../../transaction/message/message-type'; + +/** + * Test command message for alpha endpoint + */ +@messageInfo(MessageType.Command, 'alpha', 'alpha-command', 1, 0, 0) +export class AlphaCommand { + public name!: string; +} diff --git a/src/typescript/src/transport/in-memory/__tests__/alpha-event.ts b/src/typescript/src/transport/in-memory/__tests__/alpha-event.ts new file mode 100644 index 0000000..0d5fb95 --- /dev/null +++ b/src/typescript/src/transport/in-memory/__tests__/alpha-event.ts @@ -0,0 +1,10 @@ +import { messageInfo } from '../../transaction/message/message-info'; +import { MessageType } from '../../transaction/message/message-type'; + +/** + * Test event message for alpha endpoint + */ +@messageInfo(MessageType.Event, 'alpha', 'alpha-event', 1, 0, 0) +export class AlphaEvent { + public name!: string; +} diff --git a/src/typescript/src/transport/in-memory/__tests__/in-memory-transport.test.ts b/src/typescript/src/transport/in-memory/__tests__/in-memory-transport.test.ts new file mode 100644 index 0000000..5247bcd --- /dev/null +++ b/src/typescript/src/transport/in-memory/__tests__/in-memory-transport.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { TestEnvironment } from './test-environment'; +import { AlphaCommand } from './alpha-command'; +import { AlphaEvent } from './alpha-event'; +import { PolyBusNotStartedError } from '../../poly-bus-not-started-error'; + +describe('InMemoryTransport', () => { + let testEnvironment: TestEnvironment; + + beforeEach(async () => { + testEnvironment = new TestEnvironment(); + await testEnvironment.setup(); + }); + + afterEach(async () => { + await testEnvironment.stop(); + }); + + it('Send_BeforeStarting', async () => { + // Arrange + const transaction = await testEnvironment.beta.bus.createOutgoingTransaction(); + let messageReceived = false; + testEnvironment.alpha.onMessageReceived = async () => { + // This should not be called + messageReceived = true; + }; + + // Act + const command = new AlphaCommand(); + command.name = 'Test'; + transaction.add(command); + + // Assert - should throw an error because the transport is not started + await expect(transaction.commit()).rejects.toThrow(PolyBusNotStartedError); + expect(messageReceived).toBe(false); + }); + + it('Send_AfterStarted', async () => { + // Arrange + const transaction = await testEnvironment.beta.bus.createOutgoingTransaction(); + const messageReceivedPromise = new Promise((resolve) => { + testEnvironment.alpha.onMessageReceived = async () => { + resolve(true); + }; + }); + + // Act - send a command from the beta endpoint to alpha endpoint + await testEnvironment.start(); + const command = new AlphaCommand(); + command.name = 'Test'; + transaction.add(command); + await transaction.commit(); + const messageReceived = await messageReceivedPromise; + + // Assert + expect(messageReceived).toBe(true); + }); + + it('Send_WithExplicitEndpoint', async () => { + // Arrange + const transaction = await testEnvironment.alpha.bus.createOutgoingTransaction(); + let alphaReceived = false; + let deadLetterReceived = false; + const deadLetterPromise = new Promise((resolve) => { + testEnvironment.alpha.onMessageReceived = async () => { + // This should NOT be called + alphaReceived = true; + }; + testEnvironment.alpha.transport.deadLetterHandler = () => { + // This should be called + deadLetterReceived = true; + resolve(testEnvironment.alpha.transport.deadLetterEndpoint); + }; + }); + const endpoint = testEnvironment.alpha.transport.deadLetterEndpoint; + + // Act - send the alpha command to dead letter endpoint + await testEnvironment.start(); + const command = new AlphaCommand(); + command.name = 'Test'; + transaction.add(command, endpoint); + await transaction.commit(); + const actualEndpoint = await deadLetterPromise; + + // Assert + expect(actualEndpoint).toBe(endpoint); + expect(alphaReceived).toBe(false); + expect(deadLetterReceived).toBe(true); + }); + + it('Send_WithHeaders', async () => { + // Arrange + const headerKey = 'X-Custom-Header'; + const headerValue = 'HeaderValue'; + const transaction = await testEnvironment.alpha.bus.createOutgoingTransaction(); + const headerPromise = new Promise((resolve) => { + testEnvironment.alpha.onMessageReceived = async (incomingTransaction) => { + const value = incomingTransaction.incomingMessage.headers.get(headerKey) ?? ''; + resolve(value); + }; + }); + + // Act - send a command with a custom header + await testEnvironment.start(); + const command = new AlphaCommand(); + command.name = 'Test'; + const message = transaction.add(command); + message.headers.set(headerKey, headerValue); + await transaction.commit(); + const actualHeaderValue = await headerPromise; + + // Assert + expect(actualHeaderValue).toBe(headerValue); + }); + + it('Send_WithDelay', async () => { + // Arrange + const delay = 5000; // 5 seconds + const transaction = await testEnvironment.alpha.bus.createOutgoingTransaction(); + const startTime = Date.now(); + const elapsedPromise = new Promise((resolve) => { + testEnvironment.alpha.onMessageReceived = async () => { + const elapsed = Date.now() - startTime; + resolve(elapsed); + }; + }); + + // Act - send message with delay + await testEnvironment.start(); + const command = new AlphaCommand(); + command.name = 'Test'; + const message = transaction.add(command); + message.deliverAt = new Date(Date.now() + delay); + await transaction.commit(); + const elapsed = await elapsedPromise; + + // Assert + expect(elapsed).toBeGreaterThanOrEqual(delay - 1000); // allow 1 second of leeway + expect(elapsed).toBeLessThanOrEqual(delay + 1000); // allow 1 second of leeway + }, 10000); // 10 second timeout for this test + + it('Send_WithExpiredDelay', async () => { + // Arrange + const transaction = await testEnvironment.alpha.bus.createOutgoingTransaction(); + const messageReceivedPromise = new Promise((resolve) => { + testEnvironment.alpha.onMessageReceived = async () => { + resolve(true); + }; + }); + + // Act - schedule command to be delivered in the past + await testEnvironment.start(); + const command = new AlphaCommand(); + command.name = 'Test'; + const message = transaction.add(command); + message.deliverAt = new Date(Date.now() - 1000); // 1 second in the past + await transaction.commit(); + const messageReceived = await messageReceivedPromise; + + // Assert + expect(messageReceived).toBe(true); + }); + + it('Start_WhenAlreadyStarted', async () => { + // Act + await testEnvironment.start(); + + // Assert - starting again should not throw an error + await expect(testEnvironment.start()).resolves.not.toThrow(); + }); + + it('Subscribe_BeforeStarted', async () => { + // Arrange + const transaction = await testEnvironment.alpha.bus.createOutgoingTransaction(); + let messageReceived = false; + testEnvironment.beta.onMessageReceived = async () => { + messageReceived = true; + }; + + // Act - subscribing before starting should throw an error + await expect( + testEnvironment.beta.transport.subscribe( + testEnvironment.beta.bus.messages.getMessageInfo(AlphaEvent)! + ) + ).rejects.toThrow(PolyBusNotStartedError); + + const event = new AlphaEvent(); + event.name = 'Test'; + transaction.add(event); + + await expect(transaction.commit()).rejects.toThrow(PolyBusNotStartedError); + + // Assert + expect(messageReceived).toBe(false); + }); + + it('Subscribe', async () => { + // Arrange + const transaction = await testEnvironment.alpha.bus.createOutgoingTransaction(); + const messageReceivedPromise = new Promise((resolve) => { + testEnvironment.beta.onMessageReceived = async () => { + resolve(true); + }; + }); + await testEnvironment.start(); + + // Act - subscribe and send event + await testEnvironment.beta.transport.subscribe( + testEnvironment.beta.bus.messages.getMessageInfo(AlphaEvent)! + ); + const event = new AlphaEvent(); + event.name = 'Test'; + transaction.add(event); + await transaction.commit(); + const messageReceived = await messageReceivedPromise; + + // Assert + expect(messageReceived).toBe(true); + }); +}); diff --git a/src/typescript/src/transport/in-memory/__tests__/test-endpoint.ts b/src/typescript/src/transport/in-memory/__tests__/test-endpoint.ts new file mode 100644 index 0000000..40c8c16 --- /dev/null +++ b/src/typescript/src/transport/in-memory/__tests__/test-endpoint.ts @@ -0,0 +1,22 @@ +import { IPolyBus } from '../../../i-poly-bus'; +import { PolyBusBuilder } from '../../../poly-bus-builder'; +import { IncomingTransaction } from '../../transaction/incoming-transaction'; +import { InMemoryEndpoint } from '../in-memory-endpoint'; + +/** + * Represents a test endpoint with bus and handlers + */ +export class TestEndpoint { + public onMessageReceived: (transaction: IncomingTransaction) => Promise = async () => {}; + public bus!: IPolyBus; + public readonly builder: PolyBusBuilder = new PolyBusBuilder(); + + public get transport(): InMemoryEndpoint { + return this.bus.transport as InMemoryEndpoint; + } + + public async handler(transaction: IncomingTransaction, next: () => Promise): Promise { + await this.onMessageReceived(transaction); + await next(); + } +} diff --git a/src/typescript/src/transport/in-memory/__tests__/test-environment.ts b/src/typescript/src/transport/in-memory/__tests__/test-environment.ts new file mode 100644 index 0000000..5752257 --- /dev/null +++ b/src/typescript/src/transport/in-memory/__tests__/test-environment.ts @@ -0,0 +1,53 @@ +import { InMemoryMessageBroker } from '../in-memory-message-broker'; +import { TestEndpoint } from './test-endpoint'; +import { AlphaCommand } from './alpha-command'; +import { AlphaEvent } from './alpha-event'; +import { JsonHandlers } from '../../transaction/message/handlers/serializers/json-handlers'; + +/** + * Test environment for InMemory transport tests + */ +export class TestEnvironment { + public readonly inMemoryMessageBroker: InMemoryMessageBroker = new InMemoryMessageBroker(); + public readonly alpha: TestEndpoint = new TestEndpoint(); + public readonly beta: TestEndpoint = new TestEndpoint(); + + public async setup(): Promise { + await this.setupEndpoint(this.alpha, 'alpha'); + await this.setupEndpoint(this.beta, 'beta'); + } + + private async setupEndpoint(testEndpoint: TestEndpoint, name: string): Promise { + const jsonHandlers = new JsonHandlers(); + + // add handlers for incoming messages + testEndpoint.builder.incomingPipeline.push(jsonHandlers.deserializer.bind(jsonHandlers)); + testEndpoint.builder.incomingPipeline.push(testEndpoint.handler.bind(testEndpoint)); + + // add messages + testEndpoint.builder.messages.add(AlphaCommand); + testEndpoint.builder.messages.add(AlphaEvent); + testEndpoint.builder.name = name; + + // add handlers for outgoing messages + testEndpoint.builder.outgoingPipeline.push(jsonHandlers.serializer.bind(jsonHandlers)); + + // configure InMemory transport + testEndpoint.builder.transportFactory = this.inMemoryMessageBroker.addEndpoint.bind( + this.inMemoryMessageBroker + ); + + // create the bus instance + testEndpoint.bus = await testEndpoint.builder.build(); + } + + public async start(): Promise { + await this.alpha.bus.start(); + await this.beta.bus.start(); + } + + public async stop(): Promise { + await this.alpha.bus.stop(); + await this.beta.bus.stop(); + } +} diff --git a/src/typescript/src/transport/in-memory/in-memory-endpoint.ts b/src/typescript/src/transport/in-memory/in-memory-endpoint.ts new file mode 100644 index 0000000..814f416 --- /dev/null +++ b/src/typescript/src/transport/in-memory/in-memory-endpoint.ts @@ -0,0 +1,91 @@ +import { IPolyBus } from '../../i-poly-bus'; +import { ITransport } from '../i-transport'; +import { PolyBusNotStartedError } from '../poly-bus-not-started-error'; +import { IncomingMessage } from '../transaction/message/incoming-message'; +import { MessageInfo } from '../transaction/message/message-info'; +import { Transaction } from '../transaction/transaction'; + +/** + * An implementation of an in-memory transport endpoint. + */ +export class InMemoryEndpoint implements ITransport { + constructor( + private readonly broker: any, + public readonly bus: IPolyBus + ) {} + + /** + * Handler for dead letter messages. + */ + public deadLetterHandler?: (message: IncomingMessage) => void; + + /** + * Whether the endpoint is active. + */ + private _active: boolean = false; + + /** + * The dead letter endpoint name. + */ + public get deadLetterEndpoint(): string { + return `${this.bus.name}.dead.letters`; + } + + /** + * If active, handles an incoming message by creating a transaction and executing the handlers for the bus. + */ + public async handleMessage(message: IncomingMessage, isDeadLetter: boolean): Promise { + if (this._active) { + if (isDeadLetter) { + this.deadLetterHandler?.(message); + } else { + const transaction = await this.bus.createIncomingTransaction(message); + await transaction.commit(); + } + } + } + + public handle(transaction: Transaction): Promise { + if (!this._active) { + throw new PolyBusNotStartedError(); + } + + this.broker.send(transaction); + + return Promise.resolve(); + } + + public get supportsDelayedCommands(): boolean { + return true; + } + + public get supportsCommandMessages(): boolean { + return true; + } + + public async subscribe(messageInfo: MessageInfo): Promise { + if (!this._active) { + throw new PolyBusNotStartedError(); + } + + this._subscriptions.add(messageInfo.toString(false)); + } + + public isSubscribed(messageInfo: MessageInfo): boolean { + return this._subscriptions.has(messageInfo.toString(false)); + } + + public get supportsSubscriptions(): boolean { + return true; + } + + private readonly _subscriptions = new Set(); + + public async start(): Promise { + this._active = true; + } + + public async stop(): Promise { + this._active = false; + } +} diff --git a/src/typescript/src/transport/in-memory/in-memory-message-broker.ts b/src/typescript/src/transport/in-memory/in-memory-message-broker.ts new file mode 100644 index 0000000..6a2e08b --- /dev/null +++ b/src/typescript/src/transport/in-memory/in-memory-message-broker.ts @@ -0,0 +1,124 @@ +/* eslint-disable no-undef */ + +import { InMemoryEndpoint } from './in-memory-endpoint'; +import { IPolyBus } from '../../i-poly-bus'; +import { ITransport } from '../i-transport'; +import { PolyBusBuilder } from '../../poly-bus-builder'; +import { Transaction } from '../transaction/transaction'; +import { IncomingMessage } from '../transaction/message/incoming-message'; + +/** + * A message broker that uses in-memory transport for message passing. + */ +export class InMemoryMessageBroker { + /** + * The collection of in-memory endpoints managed by this broker. + */ + public readonly endpoints = new Map(); + + /** + * The ITransport factory method. + */ + public async addEndpoint(_builder: PolyBusBuilder, bus: IPolyBus): Promise { + const endpoint = new InMemoryEndpoint(this, bus); + this.endpoints.set(bus.name, endpoint); + return endpoint; + } + + /** + * Processes the transaction and distributes outgoing messages to the appropriate endpoints. + */ + public send(transaction: Transaction): void { + if (transaction.outgoingMessages.length === 0) { + return; + } + + // Execute asynchronously without blocking + void (async () => { + try { + await this.processTransaction(transaction); + } catch (error) { + console.error('Error processing transaction:', error); + } + })(); + } + + private async processTransaction(transaction: Transaction): Promise { + const tasks: Promise[] = []; + const now = new Date(); + + for (const message of transaction.outgoingMessages) { + for (const endpoint of this.endpoints.values()) { + const isDeadLetter = endpoint.deadLetterEndpoint === message.endpoint; + + if ( + isDeadLetter || + endpoint.bus.name === message.endpoint || + (message.endpoint === undefined && + (message.messageInfo.endpoint === endpoint.bus.name || + endpoint.isSubscribed(message.messageInfo))) + ) { + const incomingMessage = new IncomingMessage( + endpoint.bus, + message.body, + message.messageInfo + ); + incomingMessage.headers = new Map(message.headers); + + if (message.deliverAt) { + const delay = message.deliverAt.getTime() - now.getTime(); + if (delay > 0) { + this.delayedSend(endpoint, incomingMessage, delay, isDeadLetter); + continue; + } + } + + const task = endpoint.handleMessage(incomingMessage, isDeadLetter); + tasks.push(task); + } + } + } + + await Promise.all(tasks); + } + + private delayedSend( + endpoint: InMemoryEndpoint, + message: IncomingMessage, + delay: number, + isDeadLetter: boolean + ): void { + const timeoutId = setTimeout(async () => { + try { + if (!this._stopped) { + await endpoint.handleMessage(message, isDeadLetter); + } + } catch (error) { + console.error('Error delivering delayed message:', error); + } finally { + this._timeouts.delete(timeoutId); + } + }, delay); + this._timeouts.add(timeoutId); + } + + /** + * Stops all endpoints and waits for in-flight messages to be processed. + */ + public async stop(): Promise { + this._stopped = true; + + for (const endpoint of this.endpoints.values()) { + await endpoint.stop(); + } + + // Cancel all delayed messages + for (const timeoutId of this._timeouts) { + clearTimeout(timeoutId); + } + this._timeouts.clear(); + } + + private _stopped: boolean = false; + private readonly _timeouts = new Set(); +} diff --git a/src/typescript/src/transport/in-memory/in-memory-transport.ts b/src/typescript/src/transport/in-memory/in-memory-transport.ts deleted file mode 100644 index c5e10a4..0000000 --- a/src/typescript/src/transport/in-memory/in-memory-transport.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { IncomingMessage } from '../transaction/message/incoming-message'; -import { IncomingTransaction } from '../transaction/incoming-transaction'; -import { IPolyBus } from '../../i-poly-bus'; -import { ITransport } from '../i-transport'; -import { MessageInfo } from '../transaction/message/message-info'; -import { OutgoingMessage } from '../transaction/message/outgoing-message'; -import { PolyBusBuilder } from '../../poly-bus-builder'; -import { Transaction } from '../transaction/transaction'; - -export class InMemoryTransport { - public addEndpoint(_builder: PolyBusBuilder, bus: IPolyBus): ITransport { - const endpoint = new Endpoint(this, bus); - this._endpoints.set(bus.name, endpoint); - return endpoint; - } - private readonly _endpoints = new Map(); - - public async send(transaction: Transaction): Promise { - if (!this._active) { - throw new Error('Transport is not active.'); - } - - if (transaction.outgoingMessages.length === 0) { - return; - } - - let promiseResolver: () => void = () => {}; - const transactionId = Math.random().toString(36).substring(2, 11); - const promise = new Promise((resolve) => { - promiseResolver = resolve; - }); - this._transactions.set(transactionId, promise); - - try { - const tasks: Promise[] = []; - - for (const message of transaction.outgoingMessages) { - if (message.deliverAt) { - let delay = message.deliverAt.getTime() - Date.now(); - if (delay > 0) { - // eslint-disable-next-line no-undef - const timeoutId = setTimeout(async () => { - try { - const transaction = await message.bus.createTransaction(); - message.deliverAt = undefined; - transaction.outgoingMessages.push(message); - await transaction.commit(); - } catch (error) { - // eslint-disable-next-line no-undef - console.error('Error delivering delayed message:', error); - } finally { - this._timeouts.delete(transactionId); - } - }, delay); - this._timeouts.set(transactionId, timeoutId); - continue; - } - } - for (const endpoint of this._endpoints.values()) { - const task = endpoint.handle(message); - tasks.push(task); - } - } - - await Promise.all(tasks); - } finally { - promiseResolver(); - this._transactions.delete(transactionId); - } - } - // eslint-disable-next-line no-undef - private readonly _timeouts = new Map(); - - public useSubscriptions: boolean = false; - - public async start(): Promise { - this._active = true; - } - private _active: boolean = false; - - public async stop(): Promise { - this._active = false; - for (const timeoutId of this._timeouts.values()) { - // eslint-disable-next-line no-undef - clearTimeout(timeoutId); - } - this._timeouts.clear(); - await Promise.all(this._transactions.values()); - this._transactions.clear(); - } - private readonly _transactions = new Map>(); -} - -class Endpoint implements ITransport { - constructor( - private readonly transport: InMemoryTransport, - private readonly bus: IPolyBus - ) {} - - public async handle(message: OutgoingMessage): Promise { - if (!this.transport.useSubscriptions || this._subscriptions.includes(message.messageType)) { - const incomingMessage = new IncomingMessage(this.bus, message.body); - incomingMessage.headers = message.headers; - - try { - const transaction = await this.bus.createTransaction(incomingMessage) as IncomingTransaction; - await transaction.commit(); - } catch (error) { - console.error('Error processing message:', error); - } - } - } - - private readonly _subscriptions: Function[] = []; - public async subscribe(messageInfo: MessageInfo): Promise { - const type = this.bus.messages.getTypeByMessageInfo(messageInfo); - if (!type) { - throw new Error(`Message type for attribute ${messageInfo.toString()} is not registered.`); - } - this._subscriptions.push(type); - } - - public get supportsCommandMessages(): boolean { - return true; - } - - public get supportsDelayedMessages(): boolean { - return true; - } - - public get supportsSubscriptions(): boolean { - return true; - } - - public async send(transaction: Transaction): Promise { - return this.transport.send(transaction); - } - - public async start(): Promise { - return this.transport.start(); - } - - public async stop(): Promise { - return this.transport.stop(); - } -} diff --git a/src/typescript/src/transport/poly-bus-not-started-error.ts b/src/typescript/src/transport/poly-bus-not-started-error.ts new file mode 100644 index 0000000..d87b624 --- /dev/null +++ b/src/typescript/src/transport/poly-bus-not-started-error.ts @@ -0,0 +1,19 @@ +import { PolyBusError } from '../poly-bus-error'; + +/** + * A PolyBus error indicating that the bus has not been started. + */ +export class PolyBusNotStartedError extends PolyBusError { + /** + * Creates a new PolyBusNotStartedError instance. + */ + constructor() { + super(1, 'PolyBus has not been started. Please call IPolyBus.start() before using the bus.'); + this.name = 'PolyBusNotStartedError'; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, PolyBusNotStartedError); + } + } +} diff --git a/src/typescript/src/transport/transaction/transaction-factory.ts b/src/typescript/src/transport/transaction/incoming-transaction-factory.ts similarity index 65% rename from src/typescript/src/transport/transaction/transaction-factory.ts rename to src/typescript/src/transport/transaction/incoming-transaction-factory.ts index e931f2f..39f3438 100644 --- a/src/typescript/src/transport/transaction/transaction-factory.ts +++ b/src/typescript/src/transport/transaction/incoming-transaction-factory.ts @@ -1,11 +1,11 @@ import { IncomingMessage } from './message/incoming-message'; import { IPolyBus } from '../../i-poly-bus'; import { PolyBusBuilder } from '../../poly-bus-builder'; -import { Transaction } from './transaction'; +import { IncomingTransaction } from './incoming-transaction'; /** * A method for creating a new transaction for processing a request. * This should be used to integrate with external transaction systems to ensure message processing * is done within the context of a transaction. */ -export type TransactionFactory = (builder: PolyBusBuilder, bus: IPolyBus, message?: IncomingMessage) => Promise; +export type IncomingTransactionFactory = (builder: PolyBusBuilder, bus: IPolyBus, message: IncomingMessage) => Promise; diff --git a/src/typescript/src/transport/transaction/message/__tests__/messages.test.ts b/src/typescript/src/transport/transaction/message/__tests__/messages.test.ts index 775b259..dc17c93 100644 --- a/src/typescript/src/transport/transaction/message/__tests__/messages.test.ts +++ b/src/typescript/src/transport/transaction/message/__tests__/messages.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from '@jest/globals'; import { Messages } from '../messages'; import { MessageInfo, messageInfo } from '../message-info'; import { MessageType } from '../message-type'; +import { PolyBusMessageNotFoundError } from '../poly-bus-message-not-found-error'; describe('Messages', () => { let messages: Messages; @@ -11,233 +12,146 @@ describe('Messages', () => { }); // Test Message Classes - @messageInfo(MessageType.Command, 'OrderService', 'CreateOrder', 1, 0, 0) - class CreateOrderCommand { - public orderId: string = ''; - public amount: number = 0; + @messageInfo(MessageType.Command, 'polybus', 'polybus-command', 1, 0, 0) + class Command { + // Empty class for testing } - @messageInfo(MessageType.Event, 'OrderService', 'OrderCreated', 2, 1, 3) - class OrderCreatedEvent { - public orderId: string = ''; - public createdAt: Date = new Date(); - } - - @messageInfo(MessageType.Command, 'PaymentService', 'ProcessPayment', 1, 5, 2) - class ProcessPaymentCommand { - public paymentId: string = ''; - public amount: number = 0; + @messageInfo(MessageType.Event, 'polybus', 'polybus-event', 2, 1, 3) + class Event { + // Empty class for testing } class MessageWithoutAttribute { - public data: string = ''; + // Empty class without decorator } - describe('add', () => { - it('should return MessageInfo for valid message type', () => { + describe('Add', () => { + it('Add_ValidMessageType_ReturnsMessageInfo', () => { // Act - const result = messages.add(CreateOrderCommand); + const result = messages.add(Command); // Assert expect(result).not.toBeNull(); expect(result.type).toBe(MessageType.Command); - expect(result.endpoint).toBe('OrderService'); - expect(result.name).toBe('CreateOrder'); + expect(result.endpoint).toBe('polybus'); + expect(result.name).toBe('polybus-command'); expect(result.major).toBe(1); expect(result.minor).toBe(0); expect(result.patch).toBe(0); }); - it('should throw error for message type without attribute', () => { + it('Add_MessageTypeWithoutAttribute_ThrowsError', () => { // Act & Assert - expect(() => messages.add(MessageWithoutAttribute)).toThrow(); - expect(() => messages.add(MessageWithoutAttribute)).toThrow(/does not have MessageInfo metadata/); - expect(() => messages.add(MessageWithoutAttribute)).toThrow(/MessageWithoutAttribute/); + expect(() => messages.add(MessageWithoutAttribute)).toThrow(PolyBusMessageNotFoundError); }); - it('should throw error when adding same type twice', () => { + it('Add_SameTypeTwice_ThrowsError', () => { // Arrange - messages.add(CreateOrderCommand); + messages.add(Command); // Act & Assert - expect(() => messages.add(CreateOrderCommand)).toThrow(); + expect(() => messages.add(Command)).toThrow(PolyBusMessageNotFoundError); }); }); - describe('getMessageInfo', () => { - it('should return correct MessageInfo for existing type', () => { + describe('GetMessageInfo', () => { + it('GetMessageInfo_ExistingType_ReturnsCorrectMessageInfo', () => { // Arrange - messages.add(CreateOrderCommand); + messages.add(Command); // Act - const result = messages.getMessageInfo(CreateOrderCommand); + const result = messages.getMessageInfo(Command); // Assert expect(result).not.toBeNull(); - expect(result!.type).toBe(MessageType.Command); - expect(result!.endpoint).toBe('OrderService'); - expect(result!.name).toBe('CreateOrder'); - }); - - it('should return null for non-existent type', () => { - // Act - const result = messages.getMessageInfo(CreateOrderCommand); - - // Assert - expect(result).toBeNull(); - }); - }); - - describe('getHeader', () => { - it('should return correct header for existing type', () => { - // Arrange - messages.add(OrderCreatedEvent); - - // Act - const result = messages.getHeader(OrderCreatedEvent); - - // Assert - expect(result).toBe('endpoint=OrderService, type=event, name=OrderCreated, version=2.1.3'); + expect(result.type).toBe(MessageType.Command); + expect(result.endpoint).toBe('polybus'); + expect(result.name).toBe('polybus-command'); }); - it('should return null for non-existent type', () => { - // Act - const result = messages.getHeader(CreateOrderCommand); - - // Assert - expect(result).toBeNull(); + it('GetMessageInfo_NonExistentType_ThrowsError', () => { + // Act & Assert + expect(() => messages.getMessageInfo(Command)).toThrow(PolyBusMessageNotFoundError); }); }); - describe('getTypeByHeader', () => { - it('should return correct type for valid header', () => { + describe('GetTypeByMessageInfo', () => { + it('GetTypeByMessageInfo_ExistingMessageInfo_ReturnsCorrectType', () => { // Arrange - messages.add(ProcessPaymentCommand); - const header = 'endpoint=PaymentService, type=Command, name=ProcessPayment, version=1.5.2'; + messages.add(Event); + const messageInfo = new MessageInfo(MessageType.Event, 'polybus', 'polybus-event', 2, 1, 3); // Act - const result = messages.getTypeByHeader(header); + const result = messages.getTypeByMessageInfo(messageInfo); // Assert - expect(result).toBe(ProcessPaymentCommand); + expect(result).toBe(Event); }); - it('should return null for invalid header', () => { + it('GetTypeByMessageInfo_NonExistentMessageInfo_ThrowsError', () => { // Arrange - const invalidHeader = 'invalid header format'; - - // Act - const result = messages.getTypeByHeader(invalidHeader); + const messageInfo = new MessageInfo(MessageType.Command, 'unknown', 'unknown-command', 1, 0, 0); - // Assert - expect(result).toBeNull(); + // Act & Assert + expect(() => messages.getTypeByMessageInfo(messageInfo)).toThrow(PolyBusMessageNotFoundError); }); - it('should return null for non-existent message', () => { + it('GetTypeByMessageInfo_DifferentMinorPatchVersions_ReturnsType', () => { // Arrange - const header = 'endpoint=UnknownService, type=Command, name=UnknownCommand, version=1.0.0'; + messages.add(Event); // Has version 2.1.3 + const messageInfoDifferentMinor = new MessageInfo(MessageType.Event, 'polybus', 'polybus-event', 2, 5, 3); + const messageInfoDifferentPatch = new MessageInfo(MessageType.Event, 'polybus', 'polybus-event', 2, 1, 9); // Act - const result = messages.getTypeByHeader(header); + const result1 = messages.getTypeByMessageInfo(messageInfoDifferentMinor); + const result2 = messages.getTypeByMessageInfo(messageInfoDifferentPatch); // Assert - expect(result).toBeNull(); + expect(result1).toBe(Event); + expect(result2).toBe(Event); }); - it('should cache results', () => { + it('GetTypeByMessageInfo_DifferentMajorVersion_ThrowsError', () => { // Arrange - messages.add(CreateOrderCommand); - const header = 'endpoint=OrderService, type=Command, name=CreateOrder, version=1.0.0'; + messages.add(Event); // Has version 2.1.3 + const messageInfoDifferentMajor = new MessageInfo(MessageType.Event, 'polybus', 'polybus-event', 3, 1, 3); - // Act - const result1 = messages.getTypeByHeader(header); - const result2 = messages.getTypeByHeader(header); - - // Assert - expect(result1).toBe(CreateOrderCommand); - expect(result2).toBe(CreateOrderCommand); - expect(result1).toBe(result2); // Reference equality + // Act & Assert + expect(() => messages.getTypeByMessageInfo(messageInfoDifferentMajor)).toThrow(PolyBusMessageNotFoundError); }); }); - describe('getTypeByMessageInfo', () => { - it('should return correct type for existing MessageInfo', () => { - // Arrange - messages.add(OrderCreatedEvent); - const messageInfo = new MessageInfo(MessageType.Event, 'OrderService', 'OrderCreated', 2, 1, 3); - - // Act - const result = messages.getTypeByMessageInfo(messageInfo); - - // Assert - expect(result).toBe(OrderCreatedEvent); - }); - - it('should return null for non-existent MessageInfo', () => { - // Arrange - const messageInfo = new MessageInfo(MessageType.Command, 'UnknownService', 'UnknownCommand', 1, 0, 0); - - // Act - const result = messages.getTypeByMessageInfo(messageInfo); - - // Assert - expect(result).toBeNull(); - }); - - it('should return type for different minor/patch versions', () => { + describe('GetHeaderByMessageInfo', () => { + it('GetHeaderByMessageInfo_ExistingMessageInfo_ReturnsCorrectHeader', () => { // Arrange - messages.add(OrderCreatedEvent); // Has version 2.1.3 - const messageInfoDifferentMinor = new MessageInfo(MessageType.Event, 'OrderService', 'OrderCreated', 2, 5, 3); - const messageInfoDifferentPatch = new MessageInfo(MessageType.Event, 'OrderService', 'OrderCreated', 2, 1, 9); + messages.add(Command); + const messageInfo = new MessageInfo(MessageType.Command, 'polybus', 'polybus-command', 1, 0, 0); // Act - const result1 = messages.getTypeByMessageInfo(messageInfoDifferentMinor); - const result2 = messages.getTypeByMessageInfo(messageInfoDifferentPatch); + const result = messages.getHeaderByMessageInfo(messageInfo); // Assert - expect(result1).toBe(OrderCreatedEvent); - expect(result2).toBe(OrderCreatedEvent); + expect(result).not.toBeNull(); + expect(result).toBeTruthy(); + expect(result).toBe(messageInfo.toString(true)); }); - it('should return null for different major version', () => { + it('GetHeaderByMessageInfo_NonExistentMessageInfo_ThrowsError', () => { // Arrange - messages.add(OrderCreatedEvent); // Has version 2.1.3 - const messageInfoDifferentMajor = new MessageInfo(MessageType.Event, 'OrderService', 'OrderCreated', 3, 1, 3); - - // Act - const result = messages.getTypeByMessageInfo(messageInfoDifferentMajor); + const messageInfo = new MessageInfo(MessageType.Command, 'unknown', 'unknown-command', 1, 0, 0); - // Assert - expect(result).toBeNull(); + // Act & Assert + expect(() => messages.getHeaderByMessageInfo(messageInfo)).toThrow(PolyBusMessageNotFoundError); }); - }); - describe('multiple messages integration', () => { - it('should work correctly with multiple messages', () => { + it('GetHeaderByMessageInfo_DifferentMajorVersion_ThrowsError', () => { // Arrange - messages.add(CreateOrderCommand); - messages.add(OrderCreatedEvent); - messages.add(ProcessPaymentCommand); - - // Act & Assert - getMessageInfo - const commandInfo = messages.getMessageInfo(CreateOrderCommand); - const eventInfo = messages.getMessageInfo(OrderCreatedEvent); - const paymentInfo = messages.getMessageInfo(ProcessPaymentCommand); - - expect(commandInfo!.type).toBe(MessageType.Command); - expect(eventInfo!.type).toBe(MessageType.Event); - expect(paymentInfo!.endpoint).toBe('PaymentService'); + messages.add(Event); // Has version 2.1.3 + const messageInfoDifferentMajor = new MessageInfo(MessageType.Event, 'polybus', 'polybus-event', 3, 1, 3); - // Act & Assert - getHeader - const commandHeader = messages.getHeader(CreateOrderCommand); - const eventHeader = messages.getHeader(OrderCreatedEvent); - - expect(commandHeader).toContain('OrderService'); - expect(eventHeader).toContain('OrderCreated'); - - // Act & Assert - getTypeByHeader - const typeFromHeader = messages.getTypeByHeader(commandHeader!); - expect(typeFromHeader).toBe(CreateOrderCommand); + // Act & Assert + expect(() => messages.getHeaderByMessageInfo(messageInfoDifferentMajor)).toThrow(PolyBusMessageNotFoundError); }); }); }); diff --git a/src/typescript/src/transport/transaction/message/handlers/error/__tests__/error-handlers.test.ts b/src/typescript/src/transport/transaction/message/handlers/error/__tests__/error-handlers.test.ts index dc6b763..811b136 100644 --- a/src/typescript/src/transport/transaction/message/handlers/error/__tests__/error-handlers.test.ts +++ b/src/typescript/src/transport/transaction/message/handlers/error/__tests__/error-handlers.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ import { describe, it, expect, beforeEach } from '@jest/globals'; import { ErrorHandler } from '../error-handlers'; import { IncomingTransaction } from '../../../../incoming-transaction'; @@ -5,11 +6,17 @@ import { IncomingMessage } from '../../../incoming-message'; import { IPolyBus } from '../../../../../../i-poly-bus'; import { ITransport } from '../../../../../i-transport'; import { Messages } from '../../../messages'; +import { messageInfo } from '../../../message-info'; +import { MessageType } from '../../../message-type'; import { IncomingHandler } from '../../incoming-handler'; import { OutgoingHandler } from '../../outgoing-handler'; import { Transaction } from '../../../../transaction'; import { OutgoingTransaction } from '../../../../outgoing-transaction'; +// Test message class +@messageInfo(MessageType.Command, 'polybus', 'error-handler-test-message', 1, 0, 0) +class ErrorHandlerTestMessage {} + describe('ErrorHandler', () => { let testBus: IPolyBus; let incomingMessage: IncomingMessage; @@ -18,347 +25,282 @@ describe('ErrorHandler', () => { beforeEach(() => { testBus = new TestBus('TestBus'); - incomingMessage = new IncomingMessage(testBus, 'test message body'); + testBus.messages.add(ErrorHandlerTestMessage); + const messageInfo = testBus.messages.getMessageInfo(ErrorHandlerTestMessage); + incomingMessage = new IncomingMessage(testBus, '{}', messageInfo); transaction = new IncomingTransaction(testBus, incomingMessage); errorHandler = new TestableErrorHandler(); }); - describe('retrier method', () => { - it('should succeed on first attempt and not retry', async () => { - // Arrange - let nextCalled = false; - const next = async (): Promise => { - nextCalled = true; - }; - - // Act - await errorHandler.retrier(transaction, next); - - // Assert - expect(nextCalled).toBe(true); - expect(transaction.outgoingMessages).toHaveLength(0); - }); - - it('should fail once and retry immediately', async () => { - // Arrange - let callCount = 0; - const next = async (): Promise => { - callCount++; - if (callCount === 1) { - throw new Error('Test error'); - } - }; - - // Act - await errorHandler.retrier(transaction, next); - - // Assert - expect(callCount).toBe(2); - expect(transaction.outgoingMessages).toHaveLength(0); - }); - - it('should fail all immediate retries and schedule delayed retry', async () => { - // Arrange - const expectedRetryTime = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes from now - errorHandler.setNextRetryTime(expectedRetryTime); - - let callCount = 0; - const next = async (): Promise => { - callCount++; + it('Retrier_SucceedsOnFirstAttempt_DoesNotRetry', async () => { + // Arrange + let nextCalled = false; + const next = async (): Promise => { + nextCalled = true; + }; + + // Act + await errorHandler.retrier(transaction, next); + + // Assert + expect(nextCalled).toBe(true); + expect(transaction.outgoingMessages).toHaveLength(0); + }); + + it('Retrier_FailsOnce_RetriesImmediately', async () => { + // Arrange + let callCount = 0; + const next = async (): Promise => { + callCount++; + if (callCount === 1) { throw new Error('Test error'); - }; + } + }; - // Act - await errorHandler.retrier(transaction, next); + // Act + await errorHandler.retrier(transaction, next); - // Assert - expect(callCount).toBe(errorHandler.immediateRetryCount); - expect(transaction.outgoingMessages).toHaveLength(1); + // Assert + expect(callCount).toBe(2); + expect(transaction.outgoingMessages).toHaveLength(0); + }); - const delayedMessage = transaction.outgoingMessages[0]; - expect(delayedMessage.deliverAt).toEqual(expectedRetryTime); - expect(delayedMessage.headers.get(ErrorHandler.RetryCountHeader)).toBe('1'); - expect(delayedMessage.endpoint).toBe('TestBus'); - }); + it('Retrier_FailsAllImmediateRetries_SchedulesDelayedRetry', async () => { + // Arrange + const expectedRetryTime = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes from now + errorHandler.setNextRetryTime(expectedRetryTime); - it('should increment retry count correctly when existing retry count header exists', async () => { - // Arrange - incomingMessage.headers.set(ErrorHandler.RetryCountHeader, '2'); - const expectedRetryTime = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes from now - errorHandler.setNextRetryTime(expectedRetryTime); + let callCount = 0; + const next = async (): Promise => { + callCount++; + throw new Error('Test error'); + }; - const next = async (): Promise => { - throw new Error('Test error'); - }; - - // Act - await errorHandler.retrier(transaction, next); - - // Assert - expect(transaction.outgoingMessages).toHaveLength(1); - - const delayedMessage = transaction.outgoingMessages[0]; - expect(delayedMessage.headers.get(ErrorHandler.RetryCountHeader)).toBe('3'); - expect(delayedMessage.deliverAt).toEqual(expectedRetryTime); - }); - - it('should send to dead letter queue when max delayed retries exceeded', async () => { - // Arrange - incomingMessage.headers.set( - ErrorHandler.RetryCountHeader, - errorHandler.delayedRetryCount.toString() - ); - - const testException = new Error('Final error'); - const next = async (): Promise => { - throw testException; - }; - - // Act - await errorHandler.retrier(transaction, next); - - // Assert - expect(transaction.outgoingMessages).toHaveLength(1); - - const deadLetterMessage = transaction.outgoingMessages[0]; - expect(deadLetterMessage.endpoint).toBe('TestBus.Errors'); - expect(deadLetterMessage.headers.get(ErrorHandler.ErrorMessageHeader)).toBe('Final error'); - expect(deadLetterMessage.headers.get(ErrorHandler.ErrorStackTraceHeader)).toBeDefined(); - }); - - it('should use custom dead letter endpoint when specified', async () => { - // Arrange - errorHandler = new TestableErrorHandler(); - errorHandler.deadLetterEndpoint = 'CustomDeadLetter'; - - incomingMessage.headers.set( - ErrorHandler.RetryCountHeader, - errorHandler.delayedRetryCount.toString() - ); - - const next = async (): Promise => { - throw new Error('Final error'); - }; - - // Act - await errorHandler.retrier(transaction, next); - - // Assert - expect(transaction.outgoingMessages).toHaveLength(1); - - const deadLetterMessage = transaction.outgoingMessages[0]; - expect(deadLetterMessage.endpoint).toBe('CustomDeadLetter'); - }); - - it('should clear outgoing messages on each retry', async () => { - // Arrange - let callCount = 0; - const next = async (): Promise => { - callCount++; - transaction.addOutgoingMessage('some message', 'some endpoint'); - throw new Error('Test error'); - }; - - // Act - await errorHandler.retrier(transaction, next); - - // Assert - expect(callCount).toBe(errorHandler.immediateRetryCount); - // Should only have the delayed retry message, not the messages added in next() - expect(transaction.outgoingMessages).toHaveLength(1); - expect(transaction.outgoingMessages[0].headers.has(ErrorHandler.RetryCountHeader)).toBe(true); - }); - - it('should handle zero immediate retries with minimum of one', async () => { - // Arrange - errorHandler = new TestableErrorHandler(); - errorHandler.immediateRetryCount = 0; - const expectedRetryTime = new Date(Date.now() + 5 * 60 * 1000); - errorHandler.setNextRetryTime(expectedRetryTime); - - let callCount = 0; - const next = async (): Promise => { - callCount++; - throw new Error('Test error'); - }; + // Act + await errorHandler.retrier(transaction, next); + + // Assert + expect(callCount).toBe(errorHandler.immediateRetryCount); + expect(transaction.outgoingMessages).toHaveLength(1); - // Act - await errorHandler.retrier(transaction, next); + const delayedMessage = transaction.outgoingMessages[0]; + expect(delayedMessage.deliverAt).toEqual(expectedRetryTime); + expect(delayedMessage.headers.get(errorHandler.retryCountHeader)).toBe('1'); + expect(delayedMessage.endpoint).toBe('TestBus'); + }); - // Assert - expect(callCount).toBe(1); // Should enforce minimum of 1 - expect(transaction.outgoingMessages).toHaveLength(1); - expect(transaction.outgoingMessages[0].headers.get(ErrorHandler.RetryCountHeader)).toBe('1'); - }); + it('Retrier_WithExistingRetryCount_IncrementsCorrectly', async () => { + // Arrange + incomingMessage.headers.set(errorHandler.retryCountHeader, '2'); + const expectedRetryTime = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes from now + errorHandler.setNextRetryTime(expectedRetryTime); - it('should handle zero delayed retries with minimum of one', async () => { - // Arrange - errorHandler = new TestableErrorHandler(); - errorHandler.delayedRetryCount = 0; - const expectedRetryTime = new Date(Date.now() + 5 * 60 * 1000); - errorHandler.setNextRetryTime(expectedRetryTime); + const next = async (): Promise => { + throw new Error('Test error'); + }; - const next = async (): Promise => { - throw new Error('Test error'); - }; - - // Act - await errorHandler.retrier(transaction, next); - - // Assert - // Even with delayedRetryCount = 0, Math.max(1, delayedRetryCount) makes it 1 - expect(transaction.outgoingMessages).toHaveLength(1); - expect(transaction.outgoingMessages[0].headers.get(ErrorHandler.RetryCountHeader)).toBe('1'); - expect(transaction.outgoingMessages[0].deliverAt).toEqual(expectedRetryTime); - }); - - it('should succeed after some immediate retries and stop retrying', async () => { - // Arrange - let callCount = 0; - const next = async (): Promise => { - callCount++; - if (callCount < 3) { // Fail first 2 attempts - throw new Error('Test error'); - } - }; - - // Act - await errorHandler.retrier(transaction, next); - - // Assert - expect(callCount).toBe(3); - expect(transaction.outgoingMessages).toHaveLength(0); - }); - - it('should treat invalid retry count header as zero', async () => { - // Arrange - incomingMessage.headers.set(ErrorHandler.RetryCountHeader, 'invalid'); - const expectedRetryTime = new Date(Date.now() + 5 * 60 * 1000); - errorHandler.setNextRetryTime(expectedRetryTime); - - const next = async (): Promise => { + // Act + await errorHandler.retrier(transaction, next); + + // Assert + expect(transaction.outgoingMessages).toHaveLength(1); + + const delayedMessage = transaction.outgoingMessages[0]; + expect(delayedMessage.headers.get(errorHandler.retryCountHeader)).toBe('3'); + expect(delayedMessage.deliverAt).toEqual(expectedRetryTime); + }); + + it('Retrier_ExceedsMaxDelayedRetries_SendsToDeadLetter', async () => { + // Arrange + incomingMessage.headers.set( + errorHandler.retryCountHeader, + errorHandler.delayedRetryCount.toString() + ); + + const testException = new Error('Final error'); + const next = async (): Promise => { + throw testException; + }; + + // Act + await errorHandler.retrier(transaction, next); + + // Assert + expect(transaction.outgoingMessages).toHaveLength(1); + + const deadLetterMessage = transaction.outgoingMessages[0]; + expect(deadLetterMessage.endpoint).toBe('dead-letters'); + expect(deadLetterMessage.headers.get(errorHandler.errorMessageHeader)).toBe('Final error'); + expect(deadLetterMessage.headers.get(errorHandler.errorStackTraceHeader)).toBeDefined(); + }); + + it('Retrier_ClearsOutgoingMessagesOnEachRetry', async () => { + // Arrange + let callCount = 0; + const next = async (): Promise => { + callCount++; + transaction.add(new ErrorHandlerTestMessage()); + throw new Error('Test error'); + }; + + // Act + await errorHandler.retrier(transaction, next); + + // Assert + expect(callCount).toBe(errorHandler.immediateRetryCount); + // Should only have the delayed retry message, not the messages added in next() + expect(transaction.outgoingMessages).toHaveLength(1); + expect(transaction.outgoingMessages[0].headers.has(errorHandler.retryCountHeader)).toBe(true); + }); + + it('Retrier_WithZeroImmediateRetries_SchedulesDelayedRetryImmediately', async () => { + // Arrange + errorHandler = new TestableErrorHandler(); + errorHandler.immediateRetryCount = 0; + const expectedRetryTime = new Date(Date.now() + 5 * 60 * 1000); + errorHandler.setNextRetryTime(expectedRetryTime); + + let callCount = 0; + const next = async (): Promise => { + callCount++; + throw new Error('Test error'); + }; + + // Act + await errorHandler.retrier(transaction, next); + + // Assert + expect(callCount).toBe(1); // Should enforce minimum of 1 + expect(transaction.outgoingMessages).toHaveLength(1); + expect(transaction.outgoingMessages[0].headers.get(errorHandler.retryCountHeader)).toBe('1'); + }); + + it('Retrier_WithZeroDelayedRetries_StillGetsMinimumOfOne', async () => { + // Arrange + errorHandler = new TestableErrorHandler(); + errorHandler.delayedRetryCount = 0; + const expectedRetryTime = new Date(Date.now() + 5 * 60 * 1000); + errorHandler.setNextRetryTime(expectedRetryTime); + + const next = async (): Promise => { + throw new Error('Test error'); + }; + + // Act + await errorHandler.retrier(transaction, next); + + // Assert + // Even with delayedRetryCount = 0, Math.max(1, delayedRetryCount) makes it 1 + expect(transaction.outgoingMessages).toHaveLength(1); + expect(transaction.outgoingMessages[0].headers.get(errorHandler.retryCountHeader)).toBe('1'); + expect(transaction.outgoingMessages[0].deliverAt).toEqual(expectedRetryTime); + }); + + it('Retrier_SucceedsAfterSomeImmediateRetries_StopsRetrying', async () => { + // Arrange + let callCount = 0; + const next = async (): Promise => { + callCount++; + if (callCount < 3) { // Fail first 2 attempts throw new Error('Test error'); - }; - - // Act - await errorHandler.retrier(transaction, next); - - // Assert - expect(transaction.outgoingMessages).toHaveLength(1); - const delayedMessage = transaction.outgoingMessages[0]; - expect(delayedMessage.headers.get(ErrorHandler.RetryCountHeader)).toBe('1'); - }); - - it('should store error stack trace in header', async () => { - // Arrange - incomingMessage.headers.set( - ErrorHandler.RetryCountHeader, - errorHandler.delayedRetryCount.toString() - ); - - const errorWithStackTrace = new Error('Error with stack trace'); - - const next = async (): Promise => { - throw errorWithStackTrace; - }; - - // Act - await errorHandler.retrier(transaction, next); - - // Assert - expect(transaction.outgoingMessages).toHaveLength(1); - const deadLetterMessage = transaction.outgoingMessages[0]; - expect(deadLetterMessage.headers.get(ErrorHandler.ErrorStackTraceHeader)).toBeDefined(); - expect(deadLetterMessage.headers.get(ErrorHandler.ErrorStackTraceHeader)).not.toBe(''); - }); - - it('should use empty string for null stack trace', async () => { - // Arrange - incomingMessage.headers.set( - ErrorHandler.RetryCountHeader, - errorHandler.delayedRetryCount.toString() - ); - - // Create an error with null stack trace using custom error - const errorWithoutStackTrace = new ErrorWithNullStackTrace('Error without stack trace'); - - const next = async (): Promise => { - throw errorWithoutStackTrace; - }; - - // Act - await errorHandler.retrier(transaction, next); - - // Assert - expect(transaction.outgoingMessages).toHaveLength(1); - const deadLetterMessage = transaction.outgoingMessages[0]; - expect(deadLetterMessage.headers.get(ErrorHandler.ErrorStackTraceHeader)).toBe(''); - }); - - it('should handle non-Error exceptions', async () => { - // Arrange - incomingMessage.headers.set( - ErrorHandler.RetryCountHeader, - errorHandler.delayedRetryCount.toString() - ); - - const next = async (): Promise => { - throw 'String error'; // eslint-disable-line @typescript-eslint/no-throw-literal - }; - - // Act - await errorHandler.retrier(transaction, next); - - // Assert - expect(transaction.outgoingMessages).toHaveLength(1); - const deadLetterMessage = transaction.outgoingMessages[0]; - expect(deadLetterMessage.headers.get(ErrorHandler.ErrorMessageHeader)).toBe('String error'); - expect(deadLetterMessage.headers.get(ErrorHandler.ErrorStackTraceHeader)).toBe(''); - }); + } + }; + + // Act + await errorHandler.retrier(transaction, next); + + // Assert + expect(callCount).toBe(3); + expect(transaction.outgoingMessages).toHaveLength(0); }); - describe('getNextRetryTime method', () => { - it('should calculate retry time correctly with default delay', () => { - // Arrange - const handler = new ErrorHandler(); - handler.delay = 60; - const beforeTime = new Date(); + it('Retrier_InvalidRetryCountHeader_TreatsAsZero', async () => { + // Arrange + incomingMessage.headers.set(errorHandler.retryCountHeader, 'invalid'); + const expectedRetryTime = new Date(Date.now() + 5 * 60 * 1000); + errorHandler.setNextRetryTime(expectedRetryTime); - // Act - const result1 = handler.getNextRetryTime(1); - const result2 = handler.getNextRetryTime(2); - const result3 = handler.getNextRetryTime(3); + const next = async (): Promise => { + throw new Error('Test error'); + }; - const afterTime = new Date(); + // Act + await errorHandler.retrier(transaction, next); - // Assert - expect(result1.getTime()).toBeGreaterThanOrEqual(beforeTime.getTime() + 60 * 1000); - expect(result1.getTime()).toBeLessThanOrEqual(afterTime.getTime() + 60 * 1000); + // Assert + expect(transaction.outgoingMessages).toHaveLength(1); + const delayedMessage = transaction.outgoingMessages[0]; + expect(delayedMessage.headers.get(errorHandler.retryCountHeader)).toBe('1'); + }); - expect(result2.getTime()).toBeGreaterThanOrEqual(beforeTime.getTime() + 120 * 1000); - expect(result2.getTime()).toBeLessThanOrEqual(afterTime.getTime() + 120 * 1000); + it('Retrier_ExceptionStackTrace_IsStoredInHeader', async () => { + // Arrange + incomingMessage.headers.set( + errorHandler.retryCountHeader, + errorHandler.delayedRetryCount.toString() + ); - expect(result3.getTime()).toBeGreaterThanOrEqual(beforeTime.getTime() + 180 * 1000); - expect(result3.getTime()).toBeLessThanOrEqual(afterTime.getTime() + 180 * 1000); - }); + const exceptionWithStackTrace = new Error('Error with stack trace'); + + const next = async (): Promise => { + throw exceptionWithStackTrace; + }; + + // Act + await errorHandler.retrier(transaction, next); + + // Assert + expect(transaction.outgoingMessages).toHaveLength(1); + const deadLetterMessage = transaction.outgoingMessages[0]; + expect(deadLetterMessage.headers.get(errorHandler.errorStackTraceHeader)).toBeDefined(); + expect(deadLetterMessage.headers.get(errorHandler.errorStackTraceHeader)).not.toBe(''); }); - describe('static constants', () => { - it('should have correct error header constants', () => { - expect(ErrorHandler.ErrorMessageHeader).toBe('X-Error-Message'); - expect(ErrorHandler.ErrorStackTraceHeader).toBe('X-Error-Stack-Trace'); - expect(ErrorHandler.RetryCountHeader).toBe('X-Retry-Count'); - }); + it('Retrier_ExceptionWithNullStackTrace_UsesEmptyString', async () => { + // Arrange + incomingMessage.headers.set( + errorHandler.retryCountHeader, + errorHandler.delayedRetryCount.toString() + ); + + // Create an exception with null StackTrace using custom exception + const exceptionWithoutStackTrace = new ExceptionWithNullStackTrace('Error without stack trace'); + + const next = async (): Promise => { + throw exceptionWithoutStackTrace; + }; + + // Act + await errorHandler.retrier(transaction, next); + + // Assert + expect(transaction.outgoingMessages).toHaveLength(1); + const deadLetterMessage = transaction.outgoingMessages[0]; + expect(deadLetterMessage.headers.get(errorHandler.errorStackTraceHeader)).toBe(''); }); - describe('default configuration', () => { - it('should have correct default values', () => { - const handler = new ErrorHandler(); - expect(handler.delay).toBe(30); - expect(handler.delayedRetryCount).toBe(3); - expect(handler.immediateRetryCount).toBe(3); - expect(handler.deadLetterEndpoint).toBeUndefined(); - }); + it('GetNextRetryTime_DefaultImplementation_UsesDelayCorrectly', () => { + // Arrange + const handler = new ErrorHandler(); + handler.delayIncrement = 60; + const beforeTime = new Date(); + + // Act + const result1 = handler.getNextRetryTime(1); + const result2 = handler.getNextRetryTime(2); + const result3 = handler.getNextRetryTime(3); + + const afterTime = new Date(); + + // Assert + expect(result1.getTime()).toBeGreaterThanOrEqual(beforeTime.getTime() + 60 * 1000); + expect(result1.getTime()).toBeLessThanOrEqual(afterTime.getTime() + 60 * 1000); + + expect(result2.getTime()).toBeGreaterThanOrEqual(beforeTime.getTime() + 120 * 1000); + expect(result2.getTime()).toBeLessThanOrEqual(afterTime.getTime() + 120 * 1000); + + expect(result3.getTime()).toBeGreaterThanOrEqual(beforeTime.getTime() + 180 * 1000); + expect(result3.getTime()).toBeLessThanOrEqual(afterTime.getTime() + 180 * 1000); }); }); @@ -375,8 +317,8 @@ class TestableErrorHandler extends ErrorHandler { } } -// Custom error that returns undefined for stack trace -class ErrorWithNullStackTrace extends Error { +// Custom exception that returns null for StackTrace +class ExceptionWithNullStackTrace extends Error { constructor(message: string) { super(message); // Clear the stack trace by deleting the property @@ -387,8 +329,8 @@ class ErrorWithNullStackTrace extends Error { // Test implementation of IPolyBus for testing purposes class TestBus implements IPolyBus { public transport: ITransport; - public incomingHandlers: IncomingHandler[] = []; - public outgoingHandlers: OutgoingHandler[] = []; + public incomingPipeline: IncomingHandler[] = []; + public outgoingPipeline: OutgoingHandler[] = []; public messages: Messages = new Messages(); public properties: Map = new Map(); @@ -396,11 +338,12 @@ class TestBus implements IPolyBus { this.transport = new TestTransport(); } - public async createTransaction(message?: IncomingMessage): Promise { - const transaction: Transaction = message == null - ? new OutgoingTransaction(this) - : new IncomingTransaction(this, message); - return transaction; + public async createIncomingTransaction(message: IncomingMessage): Promise { + return new IncomingTransaction(this, message); + } + + public async createOutgoingTransaction(): Promise { + return new OutgoingTransaction(this); } public async send(_transaction: Transaction): Promise { @@ -418,10 +361,15 @@ class TestBus implements IPolyBus { // Simple test transport implementation class TestTransport implements ITransport { + public readonly deadLetterEndpoint = 'dead-letters'; public supportsCommandMessages = true; - public supportsDelayedMessages = true; + public supportsDelayedCommands = true; public supportsSubscriptions = false; + public async handle(_transaction: Transaction): Promise { + // Mock implementation + } + public async send(_transaction: Transaction): Promise { // Mock implementation } @@ -437,4 +385,4 @@ class TestTransport implements ITransport { public async stop(): Promise { // Mock implementation } -} \ No newline at end of file +} diff --git a/src/typescript/src/transport/transaction/message/handlers/error/error-handlers.ts b/src/typescript/src/transport/transaction/message/handlers/error/error-handlers.ts index 587e820..071d634 100644 --- a/src/typescript/src/transport/transaction/message/handlers/error/error-handlers.ts +++ b/src/typescript/src/transport/transaction/message/handlers/error/error-handlers.ts @@ -1,48 +1,55 @@ import { IncomingTransaction } from '../../../incoming-transaction'; +import { OutgoingMessage } from '../../outgoing-message'; /** - * Handles error scenarios for message processing, including retries and dead letter queues. + * A handler for processing message errors with retry logic. */ export class ErrorHandler { - public static readonly ErrorMessageHeader = 'X-Error-Message'; - public static readonly ErrorStackTraceHeader = 'X-Error-Stack-Trace'; - public static readonly RetryCountHeader = 'X-Retry-Count'; + /** + * The logger instance to use for logging. + */ + public log: typeof console = console; /** - * The delay in seconds between retry attempts. - * @default 30 + * The delay increment in seconds for each delayed retry attempt. + * The delay is calculated as: delay attempt number * delay increment. */ - public delay: number = 30; + public delayIncrement: number = 30; /** - * The number of delayed retry attempts. - * @default 3 + * How many delayed retry attempts to make before sending to the dead-letter queue. */ public delayedRetryCount: number = 3; /** - * The number of immediate retry attempts. - * @default 3 + * How many immediate retry attempts to make before applying delayed retries. */ public immediateRetryCount: number = 3; /** - * The endpoint to send messages to when all retries are exhausted. - * If not specified, defaults to {busName}.Errors + * The header key for storing error messages in dead-lettered messages. */ - public deadLetterEndpoint?: string; + public errorMessageHeader: string = 'x-error-message'; /** - * Retry handler that implements immediate and delayed retry logic. - * @param transaction The incoming transaction to process. - * @param next The next function in the pipeline to execute. + * The header key for storing error stack traces in dead-lettered messages. + */ + public errorStackTraceHeader: string = 'x-error-stack-trace'; + + /** + * The header key for storing the delayed retry count. + */ + public retryCountHeader: string = 'x-retry-count'; + + /** + * Retries the processing of a message according to the configured retry logic. */ public async retrier( transaction: IncomingTransaction, next: () => Promise ): Promise { - const headerValue = transaction.incomingMessage.headers.get(ErrorHandler.RetryCountHeader); - const delayedAttempt = headerValue ? parseInt(headerValue, 10) || 0 : 0; + const headerValue = transaction.incomingMessage.headers.get(this.retryCountHeader); + let delayedAttempt = headerValue ? parseInt(headerValue, 10) || 0 : 0; const delayedRetryCount = Math.max(1, this.delayedRetryCount); const immediateRetryCount = Math.max(1, this.immediateRetryCount); @@ -51,46 +58,56 @@ export class ErrorHandler { await next(); break; } catch (error) { - transaction.outgoingMessages.length = 0; // Clear outgoing messages + this.log.error( + `Error processing message ${transaction.incomingMessage.messageInfo} (immediate attempts: ${immediateAttempt}, delayed attempts: ${delayedAttempt}): ${error instanceof Error ? error.message : String(error)}` + ); + + transaction.outgoingMessages.length = 0; if (immediateAttempt < immediateRetryCount - 1) { continue; } - if (delayedAttempt < delayedRetryCount) { + if (transaction.incomingMessage.bus.transport.supportsDelayedCommands + && delayedAttempt < delayedRetryCount) { // Re-queue the message with a delay - const nextDelayedAttempt = delayedAttempt + 1; - - const delayedMessage = transaction.addOutgoingMessage( + delayedAttempt++; + const delayedMessage = new OutgoingMessage( + transaction.bus, transaction.incomingMessage.message, - transaction.bus.name + transaction.bus.name, + transaction.incomingMessage.messageInfo ); - delayedMessage.deliverAt = this.getNextRetryTime(nextDelayedAttempt); - delayedMessage.headers.set(ErrorHandler.RetryCountHeader, nextDelayedAttempt.toString()); + delayedMessage.deliverAt = this.getNextRetryTime(delayedAttempt); + transaction.incomingMessage.headers.forEach((value, key) => { + delayedMessage.headers.set(key, value); + }); + delayedMessage.headers.set(this.retryCountHeader, delayedAttempt.toString()); + transaction.outgoingMessages.push(delayedMessage); continue; } - const deadLetterEndpoint = this.deadLetterEndpoint ?? `${transaction.bus.name}.Errors`; - const deadLetterMessage = transaction.addOutgoingMessage( + const deadLetterMessage = new OutgoingMessage( + transaction.bus, transaction.incomingMessage.message, - deadLetterEndpoint + transaction.bus.transport.deadLetterEndpoint, + transaction.incomingMessage.messageInfo ); - deadLetterMessage.headers.set(ErrorHandler.ErrorMessageHeader, error instanceof Error ? error.message : String(error)); + transaction.incomingMessage.headers.forEach((value, key) => { + deadLetterMessage.headers.set(key, value); + }); + deadLetterMessage.headers.set(this.errorMessageHeader, error instanceof Error ? error.message : String(error)); deadLetterMessage.headers.set( - ErrorHandler.ErrorStackTraceHeader, + this.errorStackTraceHeader, error instanceof Error ? error.stack ?? '' : '' ); + transaction.outgoingMessages.push(deadLetterMessage); } } } - /** - * Calculates the next retry time based on the attempt number. - * @param attempt The current attempt number. - * @returns The Date when the next retry should be attempted. - */ public getNextRetryTime(attempt: number): Date { - return new Date(Date.now() + attempt * this.delay * 1000); + return new Date(Date.now() + attempt * this.delayIncrement * 1000); } } diff --git a/src/typescript/src/transport/transaction/message/handlers/serializers/__tests__/json-handlers.test.ts b/src/typescript/src/transport/transaction/message/handlers/serializers/__tests__/json-handlers.test.ts index dc13e4a..9704bfb 100644 --- a/src/typescript/src/transport/transaction/message/handlers/serializers/__tests__/json-handlers.test.ts +++ b/src/typescript/src/transport/transaction/message/handlers/serializers/__tests__/json-handlers.test.ts @@ -1,493 +1,57 @@ -import { describe, it, expect, beforeEach, jest } from '@jest/globals'; -import { Headers } from '../../../../../../headers'; -import { IncomingMessage } from '../../../incoming-message'; -import { IncomingTransaction } from '../../../../incoming-transaction'; -import { IPolyBus } from '../../../../../../i-poly-bus'; +import { describe, it, expect, beforeEach } from '@jest/globals'; import { JsonHandlers } from '../json-handlers'; -import { messageInfo } from '../../../message-info'; import { Messages } from '../../../messages'; -import { MessageType } from '../../../message-info'; +import { messageInfo } from '../../../message-info'; +import { MessageType } from '../../../message-type'; +import { Headers } from '../../../../../../headers'; import { OutgoingTransaction } from '../../../../outgoing-transaction'; +import { IPolyBus } from '../../../../../../i-poly-bus'; +import { ITransport } from '../../../../../i-transport'; +import { IncomingTransaction } from '../../../../incoming-transaction'; +import { IncomingMessage } from '../../../incoming-message'; -/** - * Jest tests for JsonHandlers serialization and deserialization functionality. - * - * These tests are based on the C# NUnit tests for the JsonHandlers class, - * adapted for TypeScript and Jest testing framework. The tests cover: - * - * - Deserializer functionality with valid/invalid type headers - * - Custom JSON reviver functions - * - Error handling for missing/invalid types - * - Serializer functionality with message type headers - * - Custom JSON replacer functions and content types - * - Multiple message handling - * - Configuration properties and defaults - */ +@messageInfo(MessageType.Command, 'polybus', 'json-handler-test-message', 1, 0, 0) +class JsonHandlerTestMessage { + public text: string = ''; +} -/** - * Test class for JsonHandlers serialization and deserialization - */ describe('JsonHandlers', () => { let jsonHandlers: JsonHandlers; - let mockBus: jest.Mocked; let messages: Messages; beforeEach(() => { jsonHandlers = new JsonHandlers(); messages = new Messages(); + messages.add(JsonHandlerTestMessage); + }); - mockBus = { - transport: {} as any, - incomingHandlers: [], - outgoingHandlers: [], + it('Serializer_SetsBodyAndContentType', async () => { + // Arrange + const message = new JsonHandlerTestMessage(); + message.text = 'Hello, World!'; + + const mockBus: IPolyBus = { + transport: {} as ITransport, + incomingPipeline: [], + outgoingPipeline: [], messages: messages, name: 'MockBus', - createTransaction: jest.fn(), - send: jest.fn(), - start: jest.fn(), - stop: jest.fn() + properties: new Map(), + createIncomingTransaction: async (msg: IncomingMessage) => new IncomingTransaction(mockBus, msg), + createOutgoingTransaction: async () => new OutgoingTransaction(mockBus), + send: async () => {}, + start: async () => {}, + stop: async () => {} } as any; - }); - - describe('Deserializer Tests', () => { - it('should deserialize message with valid type header', async () => { - // Arrange - const testMessage = { id: 1, name: 'Test' }; - const serializedBody = JSON.stringify(testMessage); - - @messageInfo(MessageType.Command, 'test-service', 'TestMessage', 1, 0, 0) - class TestMessage { - public id: number = 0; - public name: string = ''; - } - - messages.add(TestMessage); - const header = 'endpoint=test-service, type=Command, name=TestMessage, version=1.0.0'; - - const incomingMessage = new IncomingMessage(mockBus, serializedBody); - incomingMessage.headers.set(Headers.MessageType, header); - - const transaction = new MockIncomingTransaction(mockBus, incomingMessage); - - let nextCalled = false; - const next = async () => { nextCalled = true; }; - - // Act - await jsonHandlers.deserializer(transaction, next); - - // Assert - expect(nextCalled).toBe(true); - expect(incomingMessage.message).not.toBeNull(); - expect(incomingMessage.message).toEqual(testMessage); - }); - - it('should deserialize message with custom JSON reviver', async () => { - // Arrange - const jsonHandlers = new JsonHandlers(); - jsonHandlers.jsonReviver = (key: string, value: any) => { - if (key === 'name' && typeof value === 'string') { - return value.toUpperCase(); - } - return value; - }; - - const testMessage = { id: 2, name: 'test' }; - const serializedBody = JSON.stringify(testMessage); - - @messageInfo(MessageType.Command, 'test-service', 'TestMessage', 1, 0, 0) - class TestMessage { - public id: number = 0; - public name: string = ''; - } - - messages.add(TestMessage); - const header = 'endpoint=test-service, type=Command, name=TestMessage, version=1.0.0'; - - const incomingMessage = new IncomingMessage(mockBus, serializedBody); - incomingMessage.headers.set(Headers.MessageType, header); - - const transaction = new MockIncomingTransaction(mockBus, incomingMessage); - - let nextCalled = false; - const next = async () => { nextCalled = true; }; - - // Act - await jsonHandlers.deserializer(transaction, next); - - // Assert - expect(nextCalled).toBe(true); - expect(incomingMessage.message).toEqual({ id: 2, name: 'TEST' }); - }); - - it('should parse as generic object when type is unknown and throwOnMissingType is false', async () => { - // Arrange - const jsonHandlers = new JsonHandlers(); - jsonHandlers.throwOnMissingType = false; - - const testObject = { id: 3, name: 'Unknown' }; - const serializedBody = JSON.stringify(testObject); - const header = 'endpoint=test-service, type=Command, name=UnknownMessage, version=1.0.0'; - - const incomingMessage = new IncomingMessage(mockBus, serializedBody); - incomingMessage.headers.set(Headers.MessageType, header); - - const transaction = new MockIncomingTransaction(mockBus, incomingMessage); - - let nextCalled = false; - const next = async () => { nextCalled = true; }; - - // Act - await jsonHandlers.deserializer(transaction, next); - - // Assert - expect(nextCalled).toBe(true); - expect(incomingMessage.message).not.toBeNull(); - expect(incomingMessage.message).toEqual(testObject); - }); - - it('should throw exception when type is unknown and throwOnMissingType is true', async () => { - // Arrange - const jsonHandlers = new JsonHandlers(); - jsonHandlers.throwOnMissingType = true; - - const testObject = { id: 4, name: 'Error' }; - const serializedBody = JSON.stringify(testObject); - const header = 'endpoint=test-service, type=Command, name=UnknownMessage, version=1.0.0'; - - const incomingMessage = new IncomingMessage(mockBus, serializedBody); - incomingMessage.headers.set(Headers.MessageType, header); - - const transaction = new MockIncomingTransaction(mockBus, incomingMessage); - - const next = async () => {}; - - // Act & Assert - await expect(jsonHandlers.deserializer(transaction, next)) - .rejects.toThrow('The type header is missing, invalid, or if the type cannot be found.'); - }); - - it('should throw exception when type header is missing and throwOnMissingType is true', async () => { - // Arrange - const jsonHandlers = new JsonHandlers(); - jsonHandlers.throwOnMissingType = true; - - const incomingMessage = new IncomingMessage(mockBus, '{}'); - const transaction = new MockIncomingTransaction(mockBus, incomingMessage); - - const next = async () => {}; - - // Act & Assert - await expect(jsonHandlers.deserializer(transaction, next)) - .rejects.toThrow('The type header is missing, invalid, or if the type cannot be found.'); - }); - - it('should throw error when JSON is invalid', async () => { - // Arrange - @messageInfo(MessageType.Command, 'test-service', 'TestMessage', 1, 0, 0) - class TestMessage { - public id: number = 0; - public name: string = ''; - } - - messages.add(TestMessage); - const header = 'endpoint=test-service, type=Command, name=TestMessage, version=1.0.0'; - - const incomingMessage = new IncomingMessage(mockBus, 'invalid json'); - incomingMessage.headers.set(Headers.MessageType, header); - - const transaction = new MockIncomingTransaction(mockBus, incomingMessage); - - const next = async () => {}; - - // Act & Assert - await expect(jsonHandlers.deserializer(transaction, next)) - .rejects.toThrow('Failed to parse JSON message body:'); - }); - }); - - describe('Serializer Tests', () => { - it('should serialize message and set headers', async () => { - // Arrange - @messageInfo(MessageType.Command, 'test-service', 'TestMessage', 1, 0, 0) - class TestMessage { - public id: number; - public name: string; - - constructor(id: number, name: string) { - this.id = id; - this.name = name; - } - } - - const testMessage = new TestMessage(5, 'Serialize'); - messages.add(TestMessage); - - const mockTransaction = new MockOutgoingTransaction(mockBus); - const outgoingMessage = mockTransaction.addOutgoingMessage(testMessage); - - let nextCalled = false; - const next = async () => { nextCalled = true; }; - // Act - await jsonHandlers.serializer(mockTransaction, next); + const transaction = new OutgoingTransaction(mockBus); + const outgoingMessage = transaction.add(message); - // Assert - expect(nextCalled).toBe(true); - expect(outgoingMessage.body).not.toBeNull(); + // Act + await jsonHandlers.serializer(transaction, async () => {}); - const deserializedMessage = JSON.parse(outgoingMessage.body); - expect(deserializedMessage.id).toBe(5); - expect(deserializedMessage.name).toBe('Serialize'); - - expect(outgoingMessage.headers.get(Headers.ContentType)).toBe('application/json'); - expect(outgoingMessage.headers.get(Headers.MessageType)).toBe('endpoint=test-service, type=command, name=TestMessage, version=1.0.0'); - }); - - it('should use custom content type when specified', async () => { - // Arrange - const customContentType = 'application/custom-json'; - const jsonHandlers = new JsonHandlers(); - jsonHandlers.contentType = customContentType; - jsonHandlers.throwOnInvalidType = false; - - @messageInfo(MessageType.Command, 'test-service', 'TestMessage', 1, 0, 0) - class TestMessage { - public id: number; - public name: string; - - constructor(id: number, name: string) { - this.id = id; - this.name = name; - } - } - - const testMessage = new TestMessage(6, 'Custom'); - messages.add(TestMessage); - - const mockTransaction = new MockOutgoingTransaction(mockBus); - const outgoingMessage = mockTransaction.addOutgoingMessage(testMessage); - - let nextCalled = false; - const next = async () => { nextCalled = true; }; - - // Act - await jsonHandlers.serializer(mockTransaction, next); - - // Assert - expect(nextCalled).toBe(true); - expect(outgoingMessage.headers.get(Headers.ContentType)).toBe(customContentType); - }); - - it('should serialize with custom JSON replacer', async () => { - // Arrange - const jsonHandlers = new JsonHandlers(); - jsonHandlers.jsonReplacer = (key: string, value: any) => { - if (key === 'name' && typeof value === 'string') { - return value.toLowerCase(); - } - return value; - }; - jsonHandlers.throwOnInvalidType = false; - - @messageInfo(MessageType.Command, 'test-service', 'TestMessage', 1, 0, 0) - class TestMessage { - public id: number; - public name: string; - - constructor(id: number, name: string) { - this.id = id; - this.name = name; - } - } - - const testMessage = new TestMessage(7, 'OPTIONS'); - messages.add(TestMessage); - - const mockTransaction = new MockOutgoingTransaction(mockBus); - const outgoingMessage = mockTransaction.addOutgoingMessage(testMessage); - - let nextCalled = false; - const next = async () => { nextCalled = true; }; - - // Act - await jsonHandlers.serializer(mockTransaction, next); - - // Assert - expect(nextCalled).toBe(true); - expect(outgoingMessage.body).toContain('"name":"options"'); - }); - - it('should skip header setting when type is unknown and throwOnInvalidType is false', async () => { - // Arrange - const jsonHandlers = new JsonHandlers(); - jsonHandlers.throwOnInvalidType = false; - - class UnknownMessage { - public data: string; - - constructor(data: string) { - this.data = data; - } - } - - const testMessage = new UnknownMessage('test'); - - const mockTransaction = new MockOutgoingTransaction(mockBus); - const outgoingMessage = mockTransaction.addOutgoingMessage(testMessage, 'unknown-endpoint'); - - let nextCalled = false; - const next = async () => { nextCalled = true; }; - - // Act - await jsonHandlers.serializer(mockTransaction, next); - - // Assert - expect(nextCalled).toBe(true); - expect(outgoingMessage.body).not.toBeNull(); - expect(outgoingMessage.headers.get(Headers.ContentType)).toBe('application/json'); - expect(outgoingMessage.headers.has(Headers.MessageType)).toBe(false); - }); - - it('should throw exception when type is unknown and throwOnInvalidType is true', async () => { - // Arrange - const jsonHandlers = new JsonHandlers(); - jsonHandlers.throwOnInvalidType = true; - - class UnknownMessage { - public data: string; - - constructor(data: string) { - this.data = data; - } - } - - const testMessage = new UnknownMessage('error'); - - const mockTransaction = new MockOutgoingTransaction(mockBus); - mockTransaction.addOutgoingMessage(testMessage, 'unknown-endpoint'); - - const next = async () => {}; - - // Act & Assert - await expect(jsonHandlers.serializer(mockTransaction, next)) - .rejects.toThrow('The header has an invalid type.'); - }); - - it('should serialize multiple messages', async () => { - // Arrange - @messageInfo(MessageType.Command, 'test-service', 'TestMessage', 1, 0, 0) - class TestMessage { - public id: number; - public name: string; - - constructor(id: number, name: string) { - this.id = id; - this.name = name; - } - } - - const testMessage1 = new TestMessage(8, 'First'); - const testMessage2 = new TestMessage(9, 'Second'); - - messages.add(TestMessage); - - const mockTransaction = new MockOutgoingTransaction(mockBus); - const outgoingMessage1 = mockTransaction.addOutgoingMessage(testMessage1); - const outgoingMessage2 = mockTransaction.addOutgoingMessage(testMessage2); - - let nextCalled = false; - const next = async () => { nextCalled = true; }; - - // Act - await jsonHandlers.serializer(mockTransaction, next); - - // Assert - expect(nextCalled).toBe(true); - expect(outgoingMessage1.body).not.toBeNull(); - expect(outgoingMessage2.body).not.toBeNull(); - - const deserializedMessage1 = JSON.parse(outgoingMessage1.body); - const deserializedMessage2 = JSON.parse(outgoingMessage2.body); - - expect(deserializedMessage1.id).toBe(8); - expect(deserializedMessage1.name).toBe('First'); - expect(deserializedMessage2.id).toBe(9); - expect(deserializedMessage2.name).toBe('Second'); - }); - - it('should call next when no outgoing messages exist', async () => { - // Arrange - const mockTransaction = new MockOutgoingTransaction(mockBus); - - let nextCalled = false; - const next = async () => { nextCalled = true; }; - - // Act - await jsonHandlers.serializer(mockTransaction, next); - - // Assert - expect(nextCalled).toBe(true); - }); - }); - - describe('Configuration Properties', () => { - it('should have default content type as application/json', () => { - const handlers = new JsonHandlers(); - expect(handlers.contentType).toBe('application/json'); - }); - - it('should have default throwOnMissingType as true', () => { - const handlers = new JsonHandlers(); - expect(handlers.throwOnMissingType).toBe(true); - }); - - it('should have default throwOnInvalidType as true', () => { - const handlers = new JsonHandlers(); - expect(handlers.throwOnInvalidType).toBe(true); - }); - - it('should allow custom configuration', () => { - const handlers = new JsonHandlers(); - handlers.contentType = 'custom/type'; - handlers.throwOnMissingType = false; - handlers.throwOnInvalidType = false; - handlers.jsonReplacer = (_key, value) => value; - handlers.jsonReviver = (_key, value) => value; - - expect(handlers.contentType).toBe('custom/type'); - expect(handlers.throwOnMissingType).toBe(false); - expect(handlers.throwOnInvalidType).toBe(false); - expect(handlers.jsonReplacer).toBeDefined(); - expect(handlers.jsonReviver).toBeDefined(); - }); + // Assert + expect(outgoingMessage.body).not.toBeNull(); + expect(outgoingMessage.headers.get(Headers.ContentType)).toBe('application/json'); }); }); - -// Mock classes to support the tests -class MockIncomingTransaction extends IncomingTransaction { - constructor(bus: IPolyBus, incomingMessage: IncomingMessage) { - super(bus, incomingMessage); - } - - public override async abort(): Promise { - // Mock implementation - } - - public override async commit(): Promise { - // Mock implementation - } -} - -class MockOutgoingTransaction extends OutgoingTransaction { - constructor(bus: IPolyBus) { - super(bus); - } - - public override async abort(): Promise { - // Mock implementation - } - - public override async commit(): Promise { - // Mock implementation - } -} diff --git a/src/typescript/src/transport/transaction/message/handlers/serializers/json-handlers.ts b/src/typescript/src/transport/transaction/message/handlers/serializers/json-handlers.ts index 64c39b4..bc21232 100644 --- a/src/typescript/src/transport/transaction/message/handlers/serializers/json-handlers.ts +++ b/src/typescript/src/transport/transaction/message/handlers/serializers/json-handlers.ts @@ -3,8 +3,7 @@ import { IncomingTransaction } from '../../../incoming-transaction'; import { OutgoingTransaction } from '../../../outgoing-transaction'; /** - * JSON serialization and deserialization handlers for PolyBus messages. - * Uses standard JSON.stringify and JSON.parse methods for processing. + * Handlers for serializing and deserializing messages as JSON. */ export class JsonHandlers { /** @@ -25,75 +24,29 @@ export class JsonHandlers { public contentType: string = 'application/json'; /** - * If the type header is missing, invalid, or if the type cannot be found, throw an exception. + * The header key to use for the content type. */ - public throwOnMissingType: boolean = true; + public header: string = Headers.ContentType; /** - * If the message type is not in the list of known messages, throw an exception. - */ - public throwOnInvalidType: boolean = true; - - /** - * Deserializes incoming JSON messages. - * @param transaction The incoming transaction containing the message to deserialize. - * @param next The next handler in the pipeline. + * Deserializes incoming messages from JSON. */ public async deserializer(transaction: IncomingTransaction, next: () => Promise): Promise { - const message = transaction.incomingMessage; - - // Try to get the message type from headers - const header = message.headers.get(Headers.MessageType); - const type = header ? message.bus.messages.getTypeByHeader(header) : null; - - if (type === null && this.throwOnMissingType) { - throw new Error('The type header is missing, invalid, or if the type cannot be found.'); - } + const incomingMessage = transaction.incomingMessage; - // If we have a known type, we could potentially use it for validation - // But since TypeScript doesn't have runtime type information like C#, - // we'll just parse the JSON and trust the type system - try { - message.message = JSON.parse(message.body, this.jsonReviver); - - // If we found a type, we could set the messageType for consistency - if (type !== null) { - message.messageType = type; - } - } catch (error) { - throw new Error(`Failed to parse JSON message body: ${error instanceof Error ? error.message : 'Unknown error'}`); - } + incomingMessage.message = JSON.parse(incomingMessage.body, this.jsonReviver); await next(); } /** * Serializes outgoing messages to JSON. - * @param transaction The outgoing transaction containing messages to serialize. - * @param next The next handler in the pipeline. */ public async serializer(transaction: OutgoingTransaction, next: () => Promise): Promise { for (const message of transaction.outgoingMessages) { - // Serialize the message to JSON - try { - message.body = JSON.stringify(message.message, this.jsonReplacer); - } catch (error) { - throw new Error(`Failed to serialize message to JSON: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - - // Set content type - message.headers.set(Headers.ContentType, this.contentType); - - // Try to get and set the message type header - const header = message.bus.messages.getHeader(message.messageType); - - if (header !== null) { - message.headers.set(Headers.MessageType, header); - } else if (this.throwOnInvalidType) { - throw new Error('The header has an invalid type.'); - } + message.body = JSON.stringify(message.message, this.jsonReplacer); + message.headers.set(this.header, this.contentType); } - await next(); } } diff --git a/src/typescript/src/transport/transaction/message/incoming-message.ts b/src/typescript/src/transport/transaction/message/incoming-message.ts index 024a881..952f8fd 100644 --- a/src/typescript/src/transport/transaction/message/incoming-message.ts +++ b/src/typescript/src/transport/transaction/message/incoming-message.ts @@ -1,11 +1,13 @@ import { IPolyBus } from '../../../i-poly-bus'; import { Message } from './message'; +import { MessageInfo } from './message-info'; /** * Represents an incoming message in the transport layer. * Contains the message body, deserialized message object, and message type information. */ export class IncomingMessage extends Message { + private _messageInfo: MessageInfo; private _messageType: any; private _body: string; private _message: any; @@ -14,19 +16,34 @@ export class IncomingMessage extends Message { * Creates a new IncomingMessage instance. * @param bus The bus instance associated with the message. * @param body The message body contents. - * @param message The deserialized message object (optional, defaults to body). - * @param messageType The type of the message (optional, defaults to string). + * @param messageInfo The message info describing metadata about the message. */ - constructor(bus: IPolyBus, body: string, message?: any, messageType?: any) { + constructor(bus: IPolyBus, body: string, messageInfo: MessageInfo) { super(bus); if (!body) { throw new Error('Body parameter cannot be null or undefined'); } + if (!messageInfo) { + throw new Error('MessageInfo parameter cannot be null or undefined'); + } + this._body = body; - this._message = message ?? body; - this._messageType = messageType ?? String; + this._message = body; + this._messageInfo = messageInfo; + this._messageType = bus.messages.getTypeByMessageInfo(messageInfo); + } + + /** + * The message info describing metadata about the message. + */ + public get messageInfo(): MessageInfo { + return this._messageInfo; + } + + public set messageInfo(value: MessageInfo) { + this._messageInfo = value; } /** diff --git a/src/typescript/src/transport/transaction/message/message.ts b/src/typescript/src/transport/transaction/message/message.ts index 1ff1a54..439709e 100644 --- a/src/typescript/src/transport/transaction/message/message.ts +++ b/src/typescript/src/transport/transaction/message/message.ts @@ -5,8 +5,6 @@ import { IPolyBus } from '../../../i-poly-bus'; * Contains state dictionary, headers, and reference to the bus instance. */ export class Message { - private readonly _state: Map = new Map(); - private _headers: Map = new Map(); private readonly _bus: IPolyBus; /** @@ -23,20 +21,12 @@ export class Message { /** * State dictionary that can be used to store arbitrary data associated with the message. */ - public get state(): Map { - return this._state; - } + public state: Map = new Map(); /** * Message headers from the transport. */ - public get headers(): Map { - return this._headers; - } - - public set headers(value: Map) { - this._headers = value || new Map(); - } + public headers: Map = new Map(); /** * The bus instance associated with the message. diff --git a/src/typescript/src/transport/transaction/message/messages.ts b/src/typescript/src/transport/transaction/message/messages.ts index bdb3d48..4d21ebb 100644 --- a/src/typescript/src/transport/transaction/message/messages.ts +++ b/src/typescript/src/transport/transaction/message/messages.ts @@ -1,4 +1,5 @@ import { MessageInfo } from './message-info'; +import { PolyBusMessageNotFoundError } from './poly-bus-message-not-found-error'; /** * Interface for storing message type and metadata information @@ -9,57 +10,36 @@ interface MessageEntry { } /** - * A collection of message types and their associated message headers. + * A collection of message types and their associated message headers and attributes. */ export class Messages { - private readonly _map = new Map(); - private readonly _types = new Map(); + protected types: Map = new Map(); /** * Gets the message attribute associated with the specified type. + * @returns The MessageInfo associated with the specified type. + * @throws PolyBusMessageNotFoundError If no message attribute is found for the specified type. */ - public getMessageInfo(type: Function): MessageInfo | null { - const entry = this._types.get(type); - return entry ? entry.attribute : null; + public getMessageInfo(type: Function): MessageInfo { + const entry = this.types.get(type); + if (!entry) { + throw new PolyBusMessageNotFoundError(); + } + return entry.attribute; } /** - * Attempts to get the message type (constructor function) associated with the specified header. - * @param header The message header string to look up - * @returns If found, returns the message constructor function; otherwise, returns null. + * Gets the message header associated with the specified attribute. + * @returns The message header associated with the specified attribute. + * @throws PolyBusMessageNotFoundError If no message header is found for the specified attribute. */ - public getTypeByHeader(header: string): Function | null { - const attribute = MessageInfo.getAttributeFromHeader(header); - if (!attribute) { - return null; - } - - // Check if we already have this header cached - if (this._map.has(header)) { - return this._map.get(header) || null; - } - - // Find the type that matches this attribute - for (const [type, entry] of this._types.entries()) { - if (entry.attribute.equals(attribute)) { - this._map.set(header, type); - return type; + public getHeaderByMessageInfo(messageInfo: MessageInfo): string { + for (const entry of this.types.values()) { + if (entry.attribute.equals(messageInfo)) { + return messageInfo.toString(true); } } - - // Not found, cache null result - this._map.set(header, null); - return null; - } - - /** - * Attempts to get the message header associated with the specified type. - * @param type The message constructor function to look up - * @returns If found, returns the message header; otherwise, returns null. - */ - public getHeader(type: Function): string | null { - const entry = this._types.get(type); - return entry ? entry.header : null; + throw new PolyBusMessageNotFoundError(); } /** @@ -67,39 +47,39 @@ export class Messages { * The message type must have MessageInfo metadata defined via the @messageInfo decorator. * @param messageType The message constructor function to add * @returns The MessageInfo associated with the message type. - * @throws Error if the type does not have MessageInfo metadata - * @throws Error if the type has already been added + * @throws PolyBusMessageNotFoundError If the message type does not have a message info attribute defined. + * @throws PolyBusMessageNotFoundError If the type has already been added. */ public add(messageType: Function): MessageInfo { - if (this._types.has(messageType)) { - throw new Error(`Type ${messageType.name} has already been added to the Messages collection.`); - } - const attribute = MessageInfo.getMetadata(messageType); if (!attribute) { - throw new Error(`Type ${messageType.name} does not have MessageInfo metadata. Make sure to use the @messageInfo decorator.`); + throw new PolyBusMessageNotFoundError(); } const header = attribute.toString(true); const entry: MessageEntry = { attribute, header }; - this._types.set(messageType, entry); - this._map.set(header, messageType); + if (this.types.has(messageType)) { + throw new PolyBusMessageNotFoundError(); + } + + this.types.set(messageType, entry); return attribute; } /** - * Attempts to get the message type associated with the specified MessageInfo. + * Attempts to get the message type associated with the specified attribute. * @param messageInfo The MessageInfo to look up - * @returns If found, returns the message constructor function; otherwise, returns null. + * @returns The message type associated with the specified attribute. + * @throws PolyBusMessageNotFoundError If no message type is found for the specified message info attribute. */ - public getTypeByMessageInfo(messageInfo: MessageInfo): Function | null { - for (const [type, entry] of this._types.entries()) { + public getTypeByMessageInfo(messageInfo: MessageInfo): Function { + for (const [type, entry] of this.types.entries()) { if (entry.attribute.equals(messageInfo)) { return type; } } - return null; + throw new PolyBusMessageNotFoundError(); } } diff --git a/src/typescript/src/transport/transaction/message/outgoing-message.ts b/src/typescript/src/transport/transaction/message/outgoing-message.ts index ea0d461..407bb9d 100644 --- a/src/typescript/src/transport/transaction/message/outgoing-message.ts +++ b/src/typescript/src/transport/transaction/message/outgoing-message.ts @@ -1,5 +1,6 @@ import { IPolyBus } from '../../../i-poly-bus'; import { Message } from './message'; +import { MessageInfo } from './message-info'; /** * Represents an outgoing message in the transport layer. @@ -7,21 +8,43 @@ import { Message } from './message'; */ export class OutgoingMessage extends Message { private _body: string; - private _endpoint: string; + private _endpoint: string | undefined; private _message: any; + private _messageInfo: MessageInfo; private _messageType: Function; /** * Creates a new OutgoingMessage instance. * @param bus The bus instance associated with the message. * @param message The message object to be sent. + * @param endpoint An optional location to explicitly send the message to. + * @param messageInfo The message info describing metadata about the message. */ - constructor(bus: IPolyBus, message: any, endpoint: string) { + constructor(bus: IPolyBus, message: any, endpoint?: string, messageInfo?: MessageInfo) { super(bus); this._message = message; this._messageType = message?.constructor || Object; - this._body = message?.toString() || ''; + this._body = ''; this._endpoint = endpoint; + this._messageInfo = messageInfo ?? bus.messages.getMessageInfo(message?.constructor) ?? new MessageInfo( + 0 as any, + '', + '', + 0, + 0, + 0 + ); + } + + /** + * The message info describing metadata about the message. + */ + public get messageInfo(): MessageInfo { + return this._messageInfo; + } + + public set messageInfo(value: MessageInfo) { + this._messageInfo = value; } /** @@ -58,17 +81,16 @@ export class OutgoingMessage extends Message { } /** - * If the message is a command then this is the endpoint the message is being sent to. - * If the message is an event then this is the source endpoint the message is being sent from. + * An optional location to explicitly send the message to. */ - public get endpoint(): string { + public get endpoint(): string | undefined { return this._endpoint; } /** * Sets the endpoint for the message. */ - public set endpoint(value: string) { + public set endpoint(value: string | undefined) { this._endpoint = value; } diff --git a/src/typescript/src/transport/transaction/message/poly-bus-message-not-found-error.ts b/src/typescript/src/transport/transaction/message/poly-bus-message-not-found-error.ts new file mode 100644 index 0000000..3bb8943 --- /dev/null +++ b/src/typescript/src/transport/transaction/message/poly-bus-message-not-found-error.ts @@ -0,0 +1,19 @@ +import { PolyBusError } from '../../../poly-bus-error'; + +/** + * Is thrown when a requested type, attribute/decorator, or header was not registered with the message system. + */ +export class PolyBusMessageNotFoundError extends PolyBusError { + /** + * Creates a new PolyBusMessageNotFoundError instance. + */ + constructor() { + super(2, 'The requested type, attribute/decorator, or header was not found.'); + this.name = 'PolyBusMessageNotFoundError'; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, PolyBusMessageNotFoundError); + } + } +} diff --git a/src/typescript/src/transport/transaction/outgoing-transaction-factory.ts b/src/typescript/src/transport/transaction/outgoing-transaction-factory.ts new file mode 100644 index 0000000..3ee9c13 --- /dev/null +++ b/src/typescript/src/transport/transaction/outgoing-transaction-factory.ts @@ -0,0 +1,10 @@ +import { IPolyBus } from '../../i-poly-bus'; +import { PolyBusBuilder } from '../../poly-bus-builder'; +import { OutgoingTransaction } from './outgoing-transaction'; + +/** + * A method for creating a new transaction for processing a request. + * This should be used to integrate with external transaction systems to ensure message processing + * is done within the context of a transaction. + */ +export type OutgoingTransactionFactory = (builder: PolyBusBuilder, bus: IPolyBus) => Promise; diff --git a/src/typescript/src/transport/transaction/transaction.ts b/src/typescript/src/transport/transaction/transaction.ts index bc2a503..97c8698 100644 --- a/src/typescript/src/transport/transaction/transaction.ts +++ b/src/typescript/src/transport/transaction/transaction.ts @@ -47,15 +47,12 @@ export class Transaction { * @param message The message object to be sent. * @returns The OutgoingMessage instance that was added. */ - public addOutgoingMessage(message: any, endpoint: string | null = null): OutgoingMessage { - function getEndpoint(bus: IPolyBus): string { - const messageInfo = bus.messages.getMessageInfo(message.constructor); - if (!messageInfo) { - throw new Error(`Message type ${message.constructor.name} is not registered on bus ${bus.name}.`); - } - return messageInfo.endpoint; - } - const outgoingMessage = new OutgoingMessage(this._bus, message, endpoint ?? getEndpoint(this.bus)); + public add(message: any, endpoint: string | undefined = undefined): OutgoingMessage { + const outgoingMessage = new OutgoingMessage( + this._bus, + message, + endpoint + ); this._outgoingMessages.push(outgoingMessage); return outgoingMessage; } From 8bfdcf91a3c4fe26ebc84faaf68c8f249f307bce Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Sun, 30 Nov 2025 13:10:12 -0500 Subject: [PATCH 5/5] refactor: inconsistencies --- README.md | 12 +++++----- src/typescript/src/__tests__/headers.test.ts | 24 ++++++++++++++++++++ src/typescript/src/headers.ts | 10 ++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9848ce3..e8c72fb 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ const bus = await builder.build(); await bus.start(); // Send a message -const transaction = await bus.createTransaction(); -transaction.addOutgoingMessage({ type: 'UserCreated', userId: 123 }); +const transaction = await bus.createOutgoingTransaction(); +transaction.add({ type: 'UserCreated', userId: 123 }); await transaction.commit(); await bus.stop(); @@ -75,8 +75,8 @@ bus = await builder.build() await bus.start() # Send a message -transaction = await bus.create_transaction() -transaction.add_outgoing_message({'type': 'UserCreated', 'user_id': 123}) +transaction = await bus.create_outgoing_transaction() +transaction.add({'type': 'UserCreated', 'user_id': 123}) await transaction.commit() await bus.stop() @@ -104,8 +104,8 @@ var bus = await builder.Build(); await bus.Start(); // Send a message -var transaction = await bus.CreateTransaction(); -transaction.AddOutgoingMessage(new { Type = "UserCreated", UserId = 123 }); +var transaction = await bus.CreateOutgoingTransaction(); +transaction.Add(new { Type = "UserCreated", UserId = 123 }); await transaction.Commit(); await bus.Stop(); diff --git a/src/typescript/src/__tests__/headers.test.ts b/src/typescript/src/__tests__/headers.test.ts index 3704aeb..45fb963 100644 --- a/src/typescript/src/__tests__/headers.test.ts +++ b/src/typescript/src/__tests__/headers.test.ts @@ -2,6 +2,18 @@ import { describe, it, expect } from '@jest/globals'; import { Headers } from '../headers'; describe('Headers', () => { + describe('CorrelationId', () => { + it('should have the correct correlation id header name', () => { + expect(Headers.CorrelationId).toBe('correlation-id'); + }); + + it('should be a readonly property', () => { + // This test ensures the property is static and readonly + expect(typeof Headers.CorrelationId).toBe('string'); + expect(Headers.CorrelationId).toBeDefined(); + }); + }); + describe('ContentType', () => { it('should have the correct content-type header name', () => { expect(Headers.ContentType).toBe('content-type'); @@ -26,6 +38,18 @@ describe('Headers', () => { }); }); + describe('RequestId', () => { + it('should have the correct request id header name', () => { + expect(Headers.RequestId).toBe('request-id'); + }); + + it('should be a readonly property', () => { + // This test ensures the property is static and readonly + expect(typeof Headers.RequestId).toBe('string'); + expect(Headers.RequestId).toBeDefined(); + }); + }); + describe('Headers class structure', () => { it('should be a class with static properties', () => { expect(Headers).toBeDefined(); diff --git a/src/typescript/src/headers.ts b/src/typescript/src/headers.ts index 914a141..6d37c4c 100644 --- a/src/typescript/src/headers.ts +++ b/src/typescript/src/headers.ts @@ -2,6 +2,11 @@ * Common header names used in PolyBus. */ export class Headers { + /** + * The correlation id header name used for specifying the correlation identifier for tracking related messages. + */ + public static readonly CorrelationId = 'correlation-id'; + /** * The content type header name used for specifying the message content type (e.g., "application/json"). */ @@ -11,4 +16,9 @@ export class Headers { * The message type header name used for specifying the type of the message. */ public static readonly MessageType = 'x-type'; + + /** + * The message id header name used for specifying the unique identifier of the message. + */ + public static readonly RequestId = 'request-id'; } \ No newline at end of file