diff --git a/README.md b/README.md index 2ec9e63..6c35b1f 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,14 @@ They travel in one `request-context-values` header and reach the consumer as `Se > **Never put a secret in a value.** Headers are readable by anything that can read the queue, and a message that fails for good keeps them in its dead-letter queue, which the error-queue API returns. +Headers on every message, such as which deployment published it (`BusOptions.Headers`): + +```csharp +services.AddBus(o => o.Headers["deployment"] = configuration["Deployment:Id"]); +``` + +A header the bus sets itself (`contract`, trace, user, `Id`...) keeps its value when a name clashes, and an empty value is left out. The same caution about secrets applies. + --- ## Consuming Messages diff --git a/SW.Bus.IntegrationTests/StaticHeaderTests.cs b/SW.Bus.IntegrationTests/StaticHeaderTests.cs new file mode 100644 index 0000000..f62af04 --- /dev/null +++ b/SW.Bus.IntegrationTests/StaticHeaderTests.cs @@ -0,0 +1,59 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using RabbitMQ.Client; +using SW.PrimitiveTypes; +using Testcontainers.RabbitMq; + +namespace SW.Bus.IntegrationTests; + +public class HeaderedPlaced { public string Number { get; set; } = "SO-H"; } + +/// BusOptions.Headers go on every published message, without replacing a header the bus sets itself. +[TestClass] +public class StaticHeaderTests +{ + private static RabbitMqContainer _container = null!; + + [ClassInitialize] + public static async Task ClassInitialize(TestContext _) + { + _container = Broker.Create(); + await _container.StartAsync(); + } + + [ClassCleanup] + public static async Task ClassCleanup() => await _container.DisposeAsync(); + + [TestMethod] + public async Task Every_published_message_carries_the_configured_headers() + { + await using var harness = await BusHarness.StartAsync(_container.GetConnectionString(), o => + { + o.Headers["deployment"] = "acme"; + o.Headers["Id"] = "not-the-message-id"; + o.Headers["empty"] = ""; + }); + await using var conn = await new ConnectionFactory { Uri = new Uri(_container.GetConnectionString()) }.CreateConnectionAsync(); + await using var channel = await conn.CreateChannelAsync(); + var queue = (await channel.QueueDeclareAsync()).QueueName; + await channel.QueueBindAsync(queue, harness.Options.ProcessExchange, nameof(HeaderedPlaced).ToLower()); + + await using (var scope = harness.Services.CreateAsyncScope()) + await scope.ServiceProvider.GetRequiredService().Publish(new HeaderedPlaced()); + + BasicGetResult message = null; + for (var attempt = 0; attempt < 50 && message == null; attempt++) + { + message = await channel.BasicGetAsync(queue, autoAck: true); + if (message == null) await Task.Delay(100); + } + Assert.IsNotNull(message, "the message never arrived"); + var headers = message.BasicProperties.Headers!; + Assert.AreEqual("acme", Encoding.UTF8.GetString((byte[])headers["deployment"]!)); + Assert.AreEqual(message.BasicProperties.MessageId, Encoding.UTF8.GetString((byte[])headers["Id"]!), "the bus's own Id header wins"); + Assert.IsFalse(headers.ContainsKey("empty"), "an empty value is left out"); + } +} diff --git a/SW.Bus/BasicPublisher.cs b/SW.Bus/BasicPublisher.cs index aac5d34..71257f6 100644 --- a/SW.Bus/BasicPublisher.cs +++ b/SW.Bus/BasicPublisher.cs @@ -129,6 +129,11 @@ public async Task Publish(string messageTypeName, byte[] message, string exchang props.Headers.Add(BusOptions.SourceNodeIdHeaderName,busOptions.NodeId); props.Headers.Add("Id", props.MessageId); + // Last, so a header the bus sets itself is never replaced (and Add above can't collide). + foreach (var (name, value) in busOptions.Headers) + if (!string.IsNullOrEmpty(value) && !props.Headers.Keys.Any(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase))) + props.Headers[name] = value; + metrics.PublishStarted.Add(1); FireAndForget(new PublishStarted( DateTime.UtcNow, diff --git a/SW.Bus/BusOptions.cs b/SW.Bus/BusOptions.cs index 14ae94c..734ca25 100644 --- a/SW.Bus/BusOptions.cs +++ b/SW.Bus/BusOptions.cs @@ -194,6 +194,13 @@ public int MonitoringCacheSeconds /// public InvalidUserTokenBehavior OnInvalidUserToken { get; set; } = InvalidUserTokenBehavior.Fail; + /// + /// Headers stamped on every published message, such as which deployment published it. A header the bus + /// sets itself (contract, trace, user, correlation, Id...) keeps its value when a name here clashes with it. + /// Names are compared case-insensitively. Empty by default. + /// + public IDictionary Headers { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + /// /// Gets or sets the application name used in queue and exchange naming. /// When set, queues are named: {Exchange}.{ApplicationName}.{ConsumerName}.{MessageName}