From 9b16be20c090075860f551039dce21ade871dc24 Mon Sep 17 00:00:00 2001 From: Lewis Renfrew Date: Fri, 2 Oct 2026 10:11:36 +0100 Subject: [PATCH 1/2] RabbitPublisher: set ContentType and DeliveryMode from Message; add tests; 6.1.0 --- Linn.Common.Messaging.RabbitMQ.sln | 23 ++++- README.md | 4 +- changelog.md | 6 ++ src/JsonMessagePublisher.cs | 8 +- src/Linn.Common.Messaging.RabbitMQ.csproj | 2 +- src/RabbitMessagePublisher.cs | 14 +++ ...inn.Common.Messaging.RabbitMQ.Tests.csproj | 18 ++++ .../RabbitPublisherTests.cs | 85 +++++++++++++++++++ 8 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 tests/Linn.Common.Messaging.RabbitMQ.Tests/Linn.Common.Messaging.RabbitMQ.Tests.csproj create mode 100644 tests/Linn.Common.Messaging.RabbitMQ.Tests/RabbitPublisherTests.cs diff --git a/Linn.Common.Messaging.RabbitMQ.sln b/Linn.Common.Messaging.RabbitMQ.sln index 9046776..fb94f63 100644 --- a/Linn.Common.Messaging.RabbitMQ.sln +++ b/Linn.Common.Messaging.RabbitMQ.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.12.35506.116 d17.12 +VisualStudioVersion = 17.12.35506.116 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Linn.Common.Messaging.RabbitMQ", "src\Linn.Common.Messaging.RabbitMQ.csproj", "{C58B1BE7-9DAD-4F3A-AF6E-B9515DE0612B}" EndProject @@ -14,6 +14,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Linn.Common.Messaging.RabbitMQ.Tests", "tests\Linn.Common.Messaging.RabbitMQ.Tests\Linn.Common.Messaging.RabbitMQ.Tests.csproj", "{5F62DC6D-9E1E-4932-B350-926F444EDAA4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,8 +42,23 @@ Global {C58B1BE7-9DAD-4F3A-AF6E-B9515DE0612B}.Release|x64.Build.0 = Release|Any CPU {C58B1BE7-9DAD-4F3A-AF6E-B9515DE0612B}.Release|x86.ActiveCfg = Release|Any CPU {C58B1BE7-9DAD-4F3A-AF6E-B9515DE0612B}.Release|x86.Build.0 = Release|Any CPU + {5F62DC6D-9E1E-4932-B350-926F444EDAA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F62DC6D-9E1E-4932-B350-926F444EDAA4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F62DC6D-9E1E-4932-B350-926F444EDAA4}.Debug|x64.ActiveCfg = Debug|Any CPU + {5F62DC6D-9E1E-4932-B350-926F444EDAA4}.Debug|x64.Build.0 = Debug|Any CPU + {5F62DC6D-9E1E-4932-B350-926F444EDAA4}.Debug|x86.ActiveCfg = Debug|Any CPU + {5F62DC6D-9E1E-4932-B350-926F444EDAA4}.Debug|x86.Build.0 = Debug|Any CPU + {5F62DC6D-9E1E-4932-B350-926F444EDAA4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F62DC6D-9E1E-4932-B350-926F444EDAA4}.Release|Any CPU.Build.0 = Release|Any CPU + {5F62DC6D-9E1E-4932-B350-926F444EDAA4}.Release|x64.ActiveCfg = Release|Any CPU + {5F62DC6D-9E1E-4932-B350-926F444EDAA4}.Release|x64.Build.0 = Release|Any CPU + {5F62DC6D-9E1E-4932-B350-926F444EDAA4}.Release|x86.ActiveCfg = Release|Any CPU + {5F62DC6D-9E1E-4932-B350-926F444EDAA4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {5F62DC6D-9E1E-4932-B350-926F444EDAA4} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 235739a..bef185c 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ var publisher = new JsonMessagePublisher( await publisher.PublishAsync(new OrderDto { Id = 42, Total = 99.99m }); ``` -`JsonMessagePublisher` serialises `T` to UTF-8 JSON and sends it to the exchange with the configured routing key. Optional `headers` and `JsonSerializerOptions` can be supplied to the constructor. +`JsonMessagePublisher` serialises `T` to UTF-8 JSON and sends it to the exchange with the configured routing key. Optional `headers`, `JsonSerializerOptions`, `contentType` and `persistent` (sets delivery mode 2) can be supplied to the constructor. `RabbitPublisher` applies `Message.ContentType` and `Message.DeliveryMode` (2 = persistent, 1 = transient) to the published properties. --- @@ -193,6 +193,8 @@ var config = new RabbitChannelConfiguration( | `routingKey` | `string` | — | Routing key for published messages | | `headers` | `IReadOnlyDictionary?` | `null` | Optional message headers | | `serializerOptions` | `JsonSerializerOptions?` | `null` | Custom JSON serialisation options | +| `contentType` | `string?` | `null` | Content type set on published messages | +| `persistent` | `bool` | `false` | Publish with delivery mode 2 (persistent) | ### `ExchangeBinding` diff --git a/changelog.md b/changelog.md index 5d598f1..60e8d69 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## [6.1.0] - 2026-10-02 +### *Bug Fix* +- RabbitPublisher now sets ContentType and DeliveryMode from Message (previously ignored). DeliveryMode 2 maps to Persistent, 1 to Transient; null leaves the library default. +### *Changes* +- JsonMessagePublisher takes optional `contentType` and `persistent` constructor parameters (backward compatible). + ## [6.0.1] - 2026-08-25 ### *Bug Fix* - Remove `x-dead-letter-routing-key` from main queue arguments. Previously set to `""`, this caused RabbitMQ to overwrite the original routing key on dead-letter, making messages unroutable when requeued from the DLQ back to the original exchange. The original routing key is now preserved automatically. diff --git a/src/JsonMessagePublisher.cs b/src/JsonMessagePublisher.cs index ae8869d..2ae5bae 100644 --- a/src/JsonMessagePublisher.cs +++ b/src/JsonMessagePublisher.cs @@ -6,7 +6,9 @@ public class JsonMessagePublisher( RabbitPublisher rabbitPublisher, string routingKey, IReadOnlyDictionary? headers = null, - JsonSerializerOptions? serializerOptions = null) + JsonSerializerOptions? serializerOptions = null, + string? contentType = null, + bool persistent = false) : IPublisher { public async Task PublishAsync(T obj, CancellationToken cancellationToken = default) @@ -15,7 +17,9 @@ public async Task PublishAsync(T obj, CancellationToken cancellationToken = defa { RoutingKey = routingKey, Body = JsonSerializer.SerializeToUtf8Bytes(obj, serializerOptions), - Headers = headers ?? new Dictionary() + Headers = headers ?? new Dictionary(), + ContentType = contentType, + DeliveryMode = persistent ? 2 : null }; await rabbitPublisher.PublishAsync(msg, cancellationToken); diff --git a/src/Linn.Common.Messaging.RabbitMQ.csproj b/src/Linn.Common.Messaging.RabbitMQ.csproj index 7b651a4..72b837f 100644 --- a/src/Linn.Common.Messaging.RabbitMQ.csproj +++ b/src/Linn.Common.Messaging.RabbitMQ.csproj @@ -5,7 +5,7 @@ enable Linn.Common.Messaging.RabbitMQ Linn.Common.Messaging.RabbitMQ - 6.0.1 + 6.1.0 enable diff --git a/src/RabbitMessagePublisher.cs b/src/RabbitMessagePublisher.cs index 17a7980..0ea8b1d 100644 --- a/src/RabbitMessagePublisher.cs +++ b/src/RabbitMessagePublisher.cs @@ -12,6 +12,20 @@ public async Task PublishAsync(Message message, CancellationToken cancellationTo ? (IDictionary?)message.Headers : null }; + if (message.ContentType != null) + { + props.ContentType = message.ContentType; + } + + if (message.DeliveryMode == 2) + { + props.DeliveryMode = DeliveryModes.Persistent; + } + else if (message.DeliveryMode == 1) + { + props.DeliveryMode = DeliveryModes.Transient; + } + await channel.BasicPublishAsync( exchange: exchange, routingKey: message.RoutingKey, diff --git a/tests/Linn.Common.Messaging.RabbitMQ.Tests/Linn.Common.Messaging.RabbitMQ.Tests.csproj b/tests/Linn.Common.Messaging.RabbitMQ.Tests/Linn.Common.Messaging.RabbitMQ.Tests.csproj new file mode 100644 index 0000000..de1b456 --- /dev/null +++ b/tests/Linn.Common.Messaging.RabbitMQ.Tests/Linn.Common.Messaging.RabbitMQ.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + diff --git a/tests/Linn.Common.Messaging.RabbitMQ.Tests/RabbitPublisherTests.cs b/tests/Linn.Common.Messaging.RabbitMQ.Tests/RabbitPublisherTests.cs new file mode 100644 index 0000000..ee9f659 --- /dev/null +++ b/tests/Linn.Common.Messaging.RabbitMQ.Tests/RabbitPublisherTests.cs @@ -0,0 +1,85 @@ +using System.Text; +using FluentAssertions; +using Linn.Common.Messaging.RabbitMQ; +using NSubstitute; +using NUnit.Framework; +using RabbitMQ.Client; + +namespace Linn.Common.Messaging.RabbitMQ.Tests; + +public class RabbitPublisherTests +{ + private IChannel channel = null!; + private BasicProperties? captured; + + [SetUp] + public void SetUp() + { + this.channel = Substitute.For(); + this.captured = null; + this.channel.BasicPublishAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Do(p => this.captured = p), + Arg.Any>(), + Arg.Any()).Returns(ValueTask.CompletedTask); + } + + private Task Publish(Message m) => new RabbitPublisher(this.channel, "ex").PublishAsync(m); + + private static Message Msg(string? contentType, int? mode) => new() + { + RoutingKey = "rk", + Body = Encoding.UTF8.GetBytes("{}"), + ContentType = contentType, + DeliveryMode = mode + }; + + [Test] + public async Task ShouldSetContentTypeAndPersistent() + { + await this.Publish(Msg("application/json", 2)); + this.captured.Should().NotBeNull(); + this.captured!.ContentType.Should().Be("application/json"); + this.captured.DeliveryMode.Should().Be(DeliveryModes.Persistent); + } + + [Test] + public async Task ShouldSetTransient() + { + await this.Publish(Msg(null, 1)); + this.captured!.DeliveryMode.Should().Be(DeliveryModes.Transient); + } + + [Test] + public async Task ShouldLeaveUnsetWhenNull() + { + await this.Publish(Msg(null, null)); + this.captured!.ContentType.Should().BeNull(); + this.captured.IsDeliveryModePresent().Should().BeFalse(); + this.captured.IsContentTypePresent().Should().BeFalse(); + } + + [Test] + public async Task ShouldKeepHeaders() + { + var m = new Message + { + RoutingKey = "rk", + Headers = new Dictionary { ["a"] = "b" } + }; + await this.Publish(m); + this.captured!.Headers.Should().ContainKey("a"); + } + + [Test] + public async Task JsonPublisherPassesContentTypeAndPersistence() + { + var p = new JsonMessagePublisher( + new RabbitPublisher(this.channel, "ex"), "rk", contentType: "application/json", persistent: true); + await p.PublishAsync("x"); + this.captured!.ContentType.Should().Be("application/json"); + this.captured.DeliveryMode.Should().Be(DeliveryModes.Persistent); + } +} From f89a3cdcfbda10f7eb2729b4875adb422175663a Mon Sep 17 00:00:00 2001 From: Lewis Renfrew Date: Fri, 2 Oct 2026 12:31:55 +0100 Subject: [PATCH 2/2] JsonMessagePublisher: keep the original constructor; ContentType/Persistent as init properties; CI runs tests Addresses PR review: adding optional constructor parameters changed the CLR signature, so assemblies compiled against 6.0.x could hit MissingMethodException. The constructor is now unchanged (pinned by a reflection test) and the new options are init properties. CI gains a dotnet test step so the test project actually runs. --- .github/workflows/build-and-publish.yml | 3 +++ README.md | 9 +++++--- changelog.md | 3 ++- src/JsonMessagePublisher.cs | 16 +++++++++----- .../RabbitPublisherTests.cs | 21 +++++++++++++++++-- 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 9a75b57..185a73f 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -23,6 +23,9 @@ jobs: - name: Build run: dotnet build src --configuration Release --no-restore + + - name: Test + run: dotnet test tests/Linn.Common.Messaging.RabbitMQ.Tests --configuration Release - name: Pack if: github.event_name == 'push' && github.ref == 'refs/heads/main' diff --git a/README.md b/README.md index bef185c..d9f9e56 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ var publisher = new JsonMessagePublisher( await publisher.PublishAsync(new OrderDto { Id = 42, Total = 99.99m }); ``` -`JsonMessagePublisher` serialises `T` to UTF-8 JSON and sends it to the exchange with the configured routing key. Optional `headers`, `JsonSerializerOptions`, `contentType` and `persistent` (sets delivery mode 2) can be supplied to the constructor. `RabbitPublisher` applies `Message.ContentType` and `Message.DeliveryMode` (2 = persistent, 1 = transient) to the published properties. +`JsonMessagePublisher` serialises `T` to UTF-8 JSON and sends it to the exchange with the configured routing key. Optional `headers` and `JsonSerializerOptions` can be supplied to the constructor; set the `ContentType` and `Persistent` (delivery mode 2) init properties for those message properties, e.g. `new JsonMessagePublisher(publisher, "order.created") { ContentType = "application/vnd.example+json", Persistent = true }`. `RabbitPublisher` applies `Message.ContentType` and `Message.DeliveryMode` (2 = persistent, 1 = transient) to the published properties. --- @@ -193,8 +193,11 @@ var config = new RabbitChannelConfiguration( | `routingKey` | `string` | — | Routing key for published messages | | `headers` | `IReadOnlyDictionary?` | `null` | Optional message headers | | `serializerOptions` | `JsonSerializerOptions?` | `null` | Custom JSON serialisation options | -| `contentType` | `string?` | `null` | Content type set on published messages | -| `persistent` | `bool` | `false` | Publish with delivery mode 2 (persistent) | + +| Init property | Type | Default | Description | +|---|---|---|---| +| `ContentType` | `string?` | `null` | Content type set on published messages | +| `Persistent` | `bool` | `false` | Publish with delivery mode 2 (persistent) | ### `ExchangeBinding` diff --git a/changelog.md b/changelog.md index 60e8d69..e185078 100644 --- a/changelog.md +++ b/changelog.md @@ -4,7 +4,8 @@ ### *Bug Fix* - RabbitPublisher now sets ContentType and DeliveryMode from Message (previously ignored). DeliveryMode 2 maps to Persistent, 1 to Transient; null leaves the library default. ### *Changes* -- JsonMessagePublisher takes optional `contentType` and `persistent` constructor parameters (backward compatible). +- JsonMessagePublisher gains `ContentType` and `Persistent` init properties. The constructor signature is unchanged, so this is binary and source compatible. +- CI now runs the test project. ## [6.0.1] - 2026-08-25 ### *Bug Fix* diff --git a/src/JsonMessagePublisher.cs b/src/JsonMessagePublisher.cs index 2ae5bae..dd9abc8 100644 --- a/src/JsonMessagePublisher.cs +++ b/src/JsonMessagePublisher.cs @@ -2,15 +2,21 @@ namespace Linn.Common.Messaging.RabbitMQ; +// The constructor signature is unchanged since 5.1 (binary compatibility for assemblies compiled +// against earlier versions); newer options are init properties. public class JsonMessagePublisher( RabbitPublisher rabbitPublisher, string routingKey, IReadOnlyDictionary? headers = null, - JsonSerializerOptions? serializerOptions = null, - string? contentType = null, - bool persistent = false) + JsonSerializerOptions? serializerOptions = null) : IPublisher { + /// Content type set on published messages (e.g. a vendor media type). Default: none. + public string? ContentType { get; init; } + + /// Publish with delivery mode 2 (persistent). Default: false (broker default). + public bool Persistent { get; init; } + public async Task PublishAsync(T obj, CancellationToken cancellationToken = default) { var msg = new Message @@ -18,8 +24,8 @@ public async Task PublishAsync(T obj, CancellationToken cancellationToken = defa RoutingKey = routingKey, Body = JsonSerializer.SerializeToUtf8Bytes(obj, serializerOptions), Headers = headers ?? new Dictionary(), - ContentType = contentType, - DeliveryMode = persistent ? 2 : null + ContentType = this.ContentType, + DeliveryMode = this.Persistent ? 2 : null }; await rabbitPublisher.PublishAsync(msg, cancellationToken); diff --git a/tests/Linn.Common.Messaging.RabbitMQ.Tests/RabbitPublisherTests.cs b/tests/Linn.Common.Messaging.RabbitMQ.Tests/RabbitPublisherTests.cs index ee9f659..90cc9b4 100644 --- a/tests/Linn.Common.Messaging.RabbitMQ.Tests/RabbitPublisherTests.cs +++ b/tests/Linn.Common.Messaging.RabbitMQ.Tests/RabbitPublisherTests.cs @@ -76,10 +76,27 @@ public async Task ShouldKeepHeaders() [Test] public async Task JsonPublisherPassesContentTypeAndPersistence() { - var p = new JsonMessagePublisher( - new RabbitPublisher(this.channel, "ex"), "rk", contentType: "application/json", persistent: true); + var p = new JsonMessagePublisher(new RabbitPublisher(this.channel, "ex"), "rk") + { + ContentType = "application/json", + Persistent = true + }; await p.PublishAsync("x"); this.captured!.ContentType.Should().Be("application/json"); this.captured.DeliveryMode.Should().Be(DeliveryModes.Persistent); } + + // Binary compatibility: assemblies compiled against 5.1-6.0 call this exact constructor. + [Test] + public void JsonPublisherKeepsItsOriginalConstructorSignature() + { + var ctor = typeof(JsonMessagePublisher).GetConstructor( + new[] + { + typeof(RabbitPublisher), typeof(string), typeof(IReadOnlyDictionary), + typeof(System.Text.Json.JsonSerializerOptions) + }); + ctor.Should().NotBeNull(); + typeof(JsonMessagePublisher).GetConstructors().Should().HaveCount(1); + } }