diff --git a/.agent_instructions/generated_tests.md b/.agent_instructions/generated_tests.md
index acc03cb9ba..c280180dd4 100644
--- a/.agent_instructions/generated_tests.md
+++ b/.agent_instructions/generated_tests.md
@@ -1,6 +1,6 @@
# Generated Tests
-Brighter uses a test generation tool to produce consistent test suites across all provider implementations for both **outbox** (MSSQL, PostgreSQL, MySQL, SQLite, DynamoDB, MongoDB, GCP Spanner/Firestore) and **messaging gateway** (RabbitMQ, Kafka, AWS SNS/SQS, Redis, RocketMQ, GCP Pub/Sub, PostgreSQL, MSSQL) tests. See [ADR 0035](../docs/adr/0035-generated-test.md) for the design rationale.
+Brighter uses a test generation tool to produce consistent test suites across all provider implementations for both **outbox** (MSSQL, PostgreSQL, MySQL, SQLite, DynamoDB, MongoDB, GCP Spanner/Firestore) and **messaging gateway** (RabbitMQ, Kafka, AWS SNS/SQS, NATS, Redis, RocketMQ, GCP Pub/Sub, PostgreSQL, MSSQL) tests. See [ADR 0035](../docs/adr/0035-generated-test.md) for the design rationale.
## Key Principle
@@ -60,6 +60,7 @@ tests/Paramore.Brighter.*.Tests/
| `Paramore.Brighter.AWS.Tests` | | ✅ | SnsStandard, SnsFifo, SqsStandard, SqsFifo |
| `Paramore.Brighter.AWS.V4.Tests` | | ✅ | SnsStandard, SnsFifo, SqsStandard, SqsFifo |
| `Paramore.Brighter.Kafka.Tests` | | ✅ | Standard, PartitionKey |
+| `Paramore.Brighter.NATS.Tests` | | ✅ | single |
| `Paramore.Brighter.Redis.Tests` | | ✅ | single |
| `Paramore.Brighter.RocketMQ.Tests` | | ✅ | single |
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7088a97dc9..26b505a958 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -217,6 +217,31 @@ jobs:
- name: MQTT Transport Tests
run: dotnet test ./tests/Paramore.Brighter.MQTT.Tests/Paramore.Brighter.MQTT.Tests.csproj --filter "Category=MQTT&Fragile!=CI" --configuration Release --logger "console;verbosity=normal" --logger GitHubActions --blame -v n
+ nats-ci:
+ runs-on: ubuntu-latest
+ timeout-minutes: 8
+ needs: [build]
+
+ services:
+ nats:
+ image: nats:latest
+ ports:
+ - 4222:4222
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v7
+ - name: Setup dotnet
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: |
+ 9.0.x
+ 10.0.x
+ - name: Install dependencies
+ run: dotnet restore
+ - name: NATS Transport Tests
+ run: dotnet test ./tests/Paramore.Brighter.NATS.Tests/Paramore.Brighter.NATS.Tests.csproj --filter "Category=NATS&Fragile!=CI" --configuration Release --logger "console;verbosity=normal" --logger GitHubActions --blame -v n
+
rabbitmq-async-ci:
runs-on: ubuntu-latest
timeout-minutes: 8
diff --git a/Brighter.slnx b/Brighter.slnx
index 794ccea98c..2bcf1f837f 100644
--- a/Brighter.slnx
+++ b/Brighter.slnx
@@ -223,6 +223,7 @@
+
@@ -290,6 +291,7 @@
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 34b9e51b87..565476c508 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -104,6 +104,7 @@
+
diff --git a/docker-compose-nats.yaml b/docker-compose-nats.yaml
new file mode 100644
index 0000000000..5359d26fb5
--- /dev/null
+++ b/docker-compose-nats.yaml
@@ -0,0 +1,16 @@
+version: '3.8'
+
+services:
+ nats:
+ image: nats:latest
+ hostname: nats-server
+ # -js enables JetStream; -sd sets the stream storage directory; -m enables HTTP monitoring
+ command: ["-js", "-sd", "/data", "-m", "8222"]
+ ports:
+ - "4222:4222" # client connections (NATS_URL=nats://localhost:4222)
+ - "8222:8222" # HTTP monitoring
+ volumes:
+ - nats_data:/data
+
+volumes:
+ nats_data:
diff --git a/specs/README.md b/specs/README.md
index 8042f96db7..7de0778d2e 100644
--- a/specs/README.md
+++ b/specs/README.md
@@ -10,6 +10,7 @@ Spec numbers are not strictly sequential. Gaps exist to accommodate parallel wor
|-------|------------|
| 0001-0009 | General / early specs |
| 0010-0019 | Universal DLQ - transport implementations |
+| 0030-0039 | Transport implementations |
## Specifications
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/Extensions/MessageHeaderExtensions.cs b/src/Paramore.Brighter.MessagingGateway.NATS/Extensions/MessageHeaderExtensions.cs
new file mode 100644
index 0000000000..12046ce83a
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/Extensions/MessageHeaderExtensions.cs
@@ -0,0 +1,95 @@
+// The MIT License (MIT)
+// Copyright © 2014 Ian Cooper
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+using NATS.Client.Core;
+
+namespace Paramore.Brighter.MessagingGateway.NATS.Extensions;
+
+internal static class MessageHeaderExtensions
+{
+ public static NatsHeaders ToNatsHeaders(this MessageHeader messageHeader)
+ {
+ var headers = new NatsHeaders();
+ headers[HeadersName.Id] = messageHeader.MessageId.Value;
+ headers[HeadersName.Baggage] = messageHeader.Baggage.ToString();
+ headers[HeadersName.ContentType] = messageHeader.ContentType.ToString();
+ headers[HeadersName.CorrelationId] = messageHeader.CorrelationId.Value;
+ headers[HeadersName.MessageType] = messageHeader.MessageType.ToString();
+ headers[HeadersName.SpecVersion] = messageHeader.SpecVersion;
+ headers[HeadersName.Source] = messageHeader.Source.ToString();
+ headers[HeadersName.Time] = messageHeader.TimeStamp.ToString("O");
+
+ if (messageHeader.DataSchema != null)
+ {
+ headers[HeadersName.DataSchema] = messageHeader.DataSchema.ToString();
+ }
+
+ if (messageHeader.DataRef != null)
+ {
+ headers[HeadersName.DataRef] = messageHeader.DataRef;
+ }
+
+ if (messageHeader.JobId != null)
+ {
+ headers[HeadersName.JobId] = messageHeader.JobId.ToString();
+ }
+
+ if (!RoutingKey.IsNullOrEmpty(messageHeader.ReplyTo))
+ {
+ headers[HeadersName.ReplyTo] = messageHeader.ReplyTo.Value;
+ }
+
+ if (messageHeader.Subject != null)
+ {
+ headers[HeadersName.Subject] = messageHeader.Subject;
+ }
+
+ if (messageHeader.TraceParent != null)
+ {
+ headers[HeadersName.TraceParent] = messageHeader.TraceParent.Value;
+ }
+
+ if (messageHeader.TraceState != null)
+ {
+ headers[HeadersName.TraceState] = messageHeader.TraceState.Value;
+ }
+
+ if (!string.IsNullOrEmpty(messageHeader.Type?.Value))
+ {
+ headers[HeadersName.Type] = messageHeader.Type.Value;
+ }
+
+ if (messageHeader.WorkflowId != null)
+ {
+ headers[HeadersName.WorkflowId] = messageHeader.WorkflowId.ToString();
+ }
+
+ foreach (var keyPair in messageHeader.Baggage)
+ {
+ if(keyPair.Value != null)
+ {
+ headers[keyPair.Key] = keyPair.Value;
+ }
+ }
+
+ return headers;
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/Extensions/NatsMsgExtensions.cs b/src/Paramore.Brighter.MessagingGateway.NATS/Extensions/NatsMsgExtensions.cs
new file mode 100644
index 0000000000..bb4f5f9f53
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/Extensions/NatsMsgExtensions.cs
@@ -0,0 +1,324 @@
+// The MIT License (MIT)
+// Copyright © 2014 Ian Cooper
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+using System;
+using System.Net.Mime;
+using NATS.Client.Core;
+using NATS.Client.JetStream;
+using Paramore.Brighter.Observability;
+
+namespace Paramore.Brighter.MessagingGateway.NATS.Extensions;
+
+internal static class NatsMsgExtensions
+{
+ public static Message ToMessage(this INatsMsg natsMsg)
+ {
+ var message = new Message
+ {
+ Body = new MessageBody(natsMsg.Data ?? []),
+ Header = new MessageHeader(
+ messageId: GetMessageId(natsMsg.Headers),
+ baggage: GetBaggage(natsMsg.Headers),
+ contentType: GetContentType(natsMsg.Headers),
+ correlationId: GetCorrelationId(natsMsg.Headers),
+ dataSchema: GetDataSchema(natsMsg.Headers),
+ jobId: GetJobId(natsMsg.Headers),
+ messageType: GetMessageType(natsMsg.Headers),
+ source: GetSource(natsMsg.Headers),
+ subject: GetSubject(natsMsg.Headers),
+ timeStamp: GetTime(natsMsg.Headers),
+ topic: new RoutingKey(natsMsg.Subject),
+ traceParent: GetTraceParent(natsMsg.Headers),
+ traceState: GetTraceState(natsMsg.Headers),
+ type: GetCloudEventsType(natsMsg.Headers),
+ workflowId: GetWorkflowId(natsMsg.Headers))
+ {
+ DataRef = GetDataRef(natsMsg.Headers), SpecVersion = GetSpecVersion(natsMsg.Headers)
+ }
+ };
+
+ if (!string.IsNullOrEmpty(natsMsg.ReplyTo))
+ {
+ message.Header.ReplyTo = new RoutingKey(natsMsg.ReplyTo!);
+ }
+
+ if (natsMsg.Headers != null)
+ {
+ foreach (var keyPair in natsMsg.Headers)
+ {
+ if (keyPair.Value.Count == 1)
+ {
+ message.Header.Bag[keyPair.Key] = keyPair.Value.ToString();
+ }
+ else
+ {
+ message.Header.Bag[keyPair.Key] = keyPair.Value;
+ }
+ }
+ }
+
+ message.Header.Bag[HeadersName.NatsMessage] = natsMsg;
+ return message;
+ }
+
+ public static Message ToMessage(this INatsJSMsg natsMsg)
+ {
+ var message = new Message
+ {
+ Body = new MessageBody(natsMsg.Data ?? []),
+ Header = new MessageHeader(
+ messageId: GetMessageId(natsMsg.Headers),
+ baggage: GetBaggage(natsMsg.Headers),
+ contentType: GetContentType(natsMsg.Headers),
+ correlationId: GetCorrelationId(natsMsg.Headers),
+ dataSchema: GetDataSchema(natsMsg.Headers),
+ jobId: GetJobId(natsMsg.Headers),
+ messageType: GetMessageType(natsMsg.Headers),
+ source: GetSource(natsMsg.Headers),
+ subject: GetSubject(natsMsg.Headers),
+ timeStamp: GetTime(natsMsg.Headers),
+ topic: new RoutingKey(natsMsg.Subject),
+ traceParent: GetTraceParent(natsMsg.Headers),
+ traceState: GetTraceState(natsMsg.Headers),
+ type: GetCloudEventsType(natsMsg.Headers),
+ workflowId: GetWorkflowId(natsMsg.Headers))
+ {
+ DataRef = GetDataRef(natsMsg.Headers), SpecVersion = GetSpecVersion(natsMsg.Headers)
+ }
+ };
+
+ if (!string.IsNullOrEmpty(natsMsg.ReplyTo))
+ {
+ message.Header.ReplyTo = new RoutingKey(natsMsg.ReplyTo!);
+ }
+
+ if (natsMsg.Headers != null)
+ {
+ foreach (var keyPair in natsMsg.Headers)
+ {
+ if (keyPair.Value.Count == 1)
+ {
+ message.Header.Bag[keyPair.Key] = keyPair.Value.ToString();
+ }
+ else
+ {
+ message.Header.Bag[keyPair.Key] = keyPair.Value;
+ }
+ }
+ }
+
+ message.Header.Bag[HeadersName.NatsMessage] = natsMsg;
+ return message;
+ }
+
+ private static Id GetMessageId(NatsHeaders? headers)
+ {
+ if (headers != null && headers.TryGetValue(HeadersName.Id, out var messageId) && messageId.Count == 1)
+ {
+ return Id.Create(messageId.ToString());
+ }
+
+ return Id.Random();
+ }
+
+ private static Baggage GetBaggage(NatsHeaders? headers)
+ {
+ if (headers != null && headers.TryGetValue(HeadersName.Baggage, out var baggage) && baggage.Count == 1)
+ {
+ return Baggage.FromString(baggage.ToString());
+ }
+
+ return new Baggage();
+ }
+
+ private static ContentType GetContentType(NatsHeaders? headers)
+ {
+ if (headers != null && headers.TryGetValue(HeadersName.ContentType, out var contentType) &&
+ contentType.Count == 1)
+ {
+ return new ContentType(contentType.ToString());
+ }
+
+ return new ContentType("text/plain");
+ }
+
+ private static Id GetCorrelationId(NatsHeaders? headers)
+ {
+ if (headers != null && headers.TryGetValue(HeadersName.CorrelationId, out var correlationId) &&
+ correlationId.Count == 1)
+ {
+ return Id.Create(correlationId.ToString());
+ }
+
+ return Id.Random();
+ }
+
+
+ private static Uri? GetDataSchema(NatsHeaders? headers)
+ {
+ if (headers != null
+ && headers.TryGetValue(HeadersName.DataSchema, out var dataSchema)
+ && dataSchema.Count == 1
+ && Uri.TryCreate(dataSchema.ToString(), UriKind.RelativeOrAbsolute, out var uri))
+ {
+ return uri;
+ }
+
+ return null;
+ }
+
+ private static string? GetDataRef(NatsHeaders? headers)
+ {
+ if (headers != null
+ && headers.TryGetValue(HeadersName.DataRef, out var dataRef)
+ && dataRef.Count == 1)
+ {
+ return dataRef;
+ }
+
+ return null;
+ }
+
+
+ private static MessageType GetMessageType(NatsHeaders? headers)
+ {
+ if (headers != null
+ && headers.TryGetValue(HeadersName.MessageType, out var correlationId)
+ && correlationId.Count == 1
+ && Enum.TryParse(correlationId.ToString(), true, out var messageType))
+ {
+ return messageType;
+ }
+
+ return MessageType.MT_EVENT;
+ }
+
+ private static Id? GetJobId(NatsHeaders? headers)
+ {
+ if (headers != null
+ && headers.TryGetValue(HeadersName.JobId, out var jobId)
+ && jobId.Count == 1)
+ {
+ return Id.Create(jobId.ToString());
+ }
+
+ return null;
+ }
+
+ private static string? GetSubject(NatsHeaders? headers)
+ {
+ if (headers != null
+ && headers.TryGetValue(HeadersName.Subject, out var subject)
+ && subject.Count == 1)
+ {
+ return subject.ToString();
+ }
+
+ return null;
+ }
+
+ private static string GetSpecVersion(NatsHeaders? headers)
+ {
+ if (headers != null && headers.TryGetValue(HeadersName.SpecVersion, out var specVersion) &&
+ specVersion.Count == 1)
+ {
+ return specVersion.ToString();
+ }
+
+ return MessageHeader.DefaultSpecVersion;
+ }
+
+
+ private static Uri GetSource(NatsHeaders? headers)
+ {
+ if (headers != null
+ && headers.TryGetValue(HeadersName.Source, out var source)
+ && source.Count == 1
+ && Uri.TryCreate(source.ToString(), UriKind.RelativeOrAbsolute, out var uri))
+ {
+ return uri;
+ }
+
+ return new Uri(MessageHeader.DefaultSource);
+ }
+
+ private static DateTimeOffset GetTime(NatsHeaders? headers)
+ {
+ if (headers != null
+ && headers.TryGetValue(HeadersName.Time, out var source)
+ && source.Count == 1
+ && DateTimeOffset.TryParse(source.ToString(), out var time))
+ {
+ return time;
+ }
+
+ return DateTimeOffset.UtcNow;
+ }
+
+ private static TraceParent? GetTraceParent(NatsHeaders? headers)
+ {
+ if (headers != null
+ && headers.TryGetValue(HeadersName.TraceParent, out var traceParent)
+ && traceParent.Count == 1)
+ {
+ return new TraceParent(traceParent.ToString());
+ }
+
+ return null;
+ }
+
+ private static TraceState? GetTraceState(NatsHeaders? headers)
+ {
+ if (headers != null
+ && headers.TryGetValue(HeadersName.TraceState, out var traceParent)
+ && traceParent.Count == 1)
+ {
+ return new TraceState(traceParent.ToString());
+ }
+
+ return null;
+ }
+
+ private static CloudEventsType? GetCloudEventsType(NatsHeaders? headers)
+ {
+ if (headers != null
+ && headers.TryGetValue(HeadersName.Type, out var type)
+ && type.Count == 1
+ && !string.IsNullOrEmpty(type.ToString()))
+ {
+ return new CloudEventsType(type.ToString());
+ }
+
+ return null;
+ }
+
+ private static Id? GetWorkflowId(NatsHeaders? headers)
+ {
+ if (headers != null
+ && headers.TryGetValue(HeadersName.WorkflowId, out var jobId)
+ && jobId.Count == 1)
+ {
+ return Id.Create(jobId.ToString());
+ }
+
+ return null;
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/HeadersName.cs b/src/Paramore.Brighter.MessagingGateway.NATS/HeadersName.cs
new file mode 100644
index 0000000000..4d1cbe0135
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/HeadersName.cs
@@ -0,0 +1,83 @@
+// The MIT License (MIT)
+// Copyright © 2014 Ian Cooper
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+///
+/// Well-known NATS header names used when mapping between Brighter message headers and NATS headers.
+/// CloudEvents properties use the ce- prefix; Brighter-specific properties use the brighter- prefix.
+///
+public static class HeadersName
+{
+ /// The CloudEvents id header carrying the message id.
+ public const string Id = "ce-id";
+
+ /// The CloudEvents baggage header carrying the W3C baggage.
+ public const string Baggage = "ce-baggage";
+
+ /// The CloudEvents datacontenttype header carrying the content type.
+ public const string ContentType = "ce-datacontenttype";
+
+ /// The CloudEvents correlation id header.
+ public const string CorrelationId = "ce-correlationid";
+
+ /// The CloudEvents dataschema header.
+ public const string DataSchema = "ce-dataschema";
+
+ /// The CloudEvents dataref header carrying a claim-check reference.
+ public const string DataRef = "ce-dataref";
+
+ /// The Brighter header carrying the job id.
+ public const string JobId = "brighter-jobid";
+
+ /// The Brighter header carrying the .
+ public const string MessageType = "brighter-messagetype";
+
+ /// The CloudEvents reply-to header.
+ public const string ReplyTo = "ce-replyto";
+
+ /// The CloudEvents subject header.
+ public const string Subject = "ce-subject";
+
+ /// The CloudEvents specversion header.
+ public const string SpecVersion = "ce-specversion";
+
+ /// The CloudEvents source header.
+ public const string Source = "ce-source";
+
+ /// The CloudEvents time header carrying the message timestamp.
+ public const string Time = "ce-time";
+
+ /// The CloudEvents traceparent header carrying the W3C trace parent.
+ public const string TraceParent = "ce-traceparent";
+
+ /// The CloudEvents tracestate header carrying the W3C trace state.
+ public const string TraceState = "ce-tracestate";
+
+ /// The CloudEvents type header.
+ public const string Type = "ce-type";
+
+ /// The Brighter header carrying the workflow id.
+ public const string WorkflowId ="brighter-workflowid";
+
+ /// The message-bag key under which the original NATS message is stored on the Brighter message.
+ public const string NatsMessage = "BrighterNats";
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/NatsChannelFactory.cs b/src/Paramore.Brighter.MessagingGateway.NATS/NatsChannelFactory.cs
new file mode 100644
index 0000000000..c30736b492
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/NatsChannelFactory.cs
@@ -0,0 +1,121 @@
+#region Licence
+
+/* The MIT License (MIT)
+Copyright © 2026 Rafael Andrade
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the “Software”), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE. */
+
+#endregion
+
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+///
+/// Factory class for creating NATS channels from or
+/// subscriptions.
+///
+public class NatsChannelFactory : IAmAChannelFactory, IAmAChannelFactoryWithScheduler
+{
+ private readonly NatsMessageConsumerFactory _consumerFactory;
+
+ ///
+ /// Gets or sets the message scheduler for delayed requeue support.
+ /// Setting this property forwards the scheduler to the underlying consumer factory.
+ ///
+ /// The , or when delayed requeue is not configured.
+ public IAmAMessageScheduler? Scheduler
+ {
+ get => _consumerFactory.Scheduler;
+ set => _consumerFactory.Scheduler = value;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The used to create the channel's consumer.
+ public NatsChannelFactory(NatsMessageConsumerFactory consumerFactory)
+ {
+ _consumerFactory = consumerFactory;
+ }
+
+ ///
+ /// Creates a synchronous NATS channel.
+ ///
+ /// A or .
+ /// A synchronous NATS channel instance.
+ /// Thrown when the subscription is not a NATS subscription.
+ public IAmAChannelSync CreateSyncChannel(Subscription subscription)
+ {
+ if (subscription is not (NatsSubscription or NatsStreamSubscription))
+ {
+ throw new ConfigurationException("We expect a NatsSubscription or NatsStreamSubscription as a parameter");
+ }
+
+ return new Channel(
+ subscription.ChannelName,
+ subscription.RoutingKey,
+ _consumerFactory.Create(subscription),
+ subscription.BufferSize);
+ }
+
+ ///
+ /// Creates an asynchronous NATS channel.
+ ///
+ /// A or .
+ /// An asynchronous NATS channel instance.
+ /// Thrown when the subscription is not a NATS subscription.
+ public IAmAChannelAsync CreateAsyncChannel(Subscription subscription)
+ {
+ if (subscription is not (NatsSubscription or NatsStreamSubscription))
+ {
+ throw new ConfigurationException("We expect a NatsSubscription or NatsStreamSubscription as a parameter");
+ }
+
+ return new ChannelAsync(
+ subscription.ChannelName,
+ subscription.RoutingKey,
+ _consumerFactory.CreateAsync(subscription),
+ subscription.BufferSize);
+ }
+
+ ///
+ /// Asynchronously creates an asynchronous NATS channel.
+ ///
+ /// A or .
+ /// A token to cancel the operation.
+ /// A task representing the asynchronous operation, with an asynchronous NATS channel instance as the result.
+ /// Thrown when the subscription is not a NATS subscription.
+ public Task CreateAsyncChannelAsync(Subscription subscription, CancellationToken ct = default)
+ {
+ if (subscription is not (NatsSubscription or NatsStreamSubscription))
+ {
+ throw new ConfigurationException("We expect a NatsSubscription or NatsStreamSubscription as a parameter");
+ }
+
+ IAmAChannelAsync channel = new ChannelAsync(
+ subscription.ChannelName,
+ subscription.RoutingKey,
+ _consumerFactory.CreateAsync(subscription),
+ subscription.BufferSize);
+
+ return Task.FromResult(channel);
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageConsumer.cs b/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageConsumer.cs
new file mode 100644
index 0000000000..defd1683fa
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageConsumer.cs
@@ -0,0 +1,236 @@
+// The MIT License (MIT)
+// Copyright © 2014 Ian Cooper
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using NATS.Client.Core;
+using Paramore.Brighter.Logging;
+using Paramore.Brighter.MessagingGateway.NATS.Extensions;
+using Paramore.Brighter.Tasks;
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+///
+/// Consumes messages from a core NATS subject on behalf of a .
+///
+///
+/// Core NATS is at-most-once: there is no broker-side persistence or acknowledgement, so
+/// , , , and
+/// are no-ops. republishes the message to its subject, which can yield duplicates;
+/// use a when redelivery semantics are required. A requested requeue delay
+/// is honored only when a scheduler is configured, as core NATS has no delayed delivery.
+///
+/// The to read messages from.
+/// The used to republish on requeue.
+/// An optional used to honor delayed requeue; optional, defaults to .
+public partial class NatsMessageConsumer(INatsSub subscription, INatsClient client, IAmAMessageScheduler? scheduler = null) : IAmAMessageConsumerAsync, IAmAMessageConsumerSync
+{
+ private static readonly ILogger s_logger = ApplicationLogging.CreateLogger();
+
+ ///
+ /// No-op: core NATS has no acknowledgement protocol.
+ ///
+ /// The to acknowledge.
+ /// Ignored.
+ /// A completed .
+ public Task AcknowledgeAsync(Message message, CancellationToken cancellationToken = default)
+ {
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// No-op: core NATS has no rejection protocol; the message is simply dropped.
+ ///
+ /// The to reject.
+ /// The that explains why we rejected the message.
+ /// Ignored.
+ /// Always .
+ public Task RejectAsync(Message message, MessageRejectionReason? reason = null,
+ CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(true);
+ }
+
+ ///
+ /// No-op: a core NATS subject cannot be purged.
+ ///
+ /// Ignored.
+ /// A completed .
+ public Task PurgeAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Receives the next message from the subscription, waiting up to .
+ ///
+ /// How long to wait for a message; if omitted, waits until is cancelled.
+ /// Cancels the wait.
+ /// An array with the received , or an empty if none arrived before the timeout.
+ public async Task ReceiveAsync(TimeSpan? timeOut = null, CancellationToken cancellationToken = default)
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ if (timeOut.HasValue)
+ {
+ cts.CancelAfter(timeOut.Value);
+ }
+
+ try
+ {
+ var message = await subscription.Msgs.ReadAsync(cts.Token);
+ return [message.ToMessage()];
+ }
+ catch (OperationCanceledException)
+ {
+ Log.NoMessagesAvailable(s_logger);
+ return [new Message()];
+ }
+ }
+
+ ///
+ /// No-op: core NATS has no negative-acknowledgement protocol.
+ ///
+ /// The to nack.
+ /// Ignored.
+ /// A completed .
+ public Task NackAsync(Message message, CancellationToken cancellationToken = default)
+ {
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Requeues the message by republishing it to its subject, or via the configured scheduler when a delay is requested.
+ ///
+ ///
+ /// The republished message is a new delivery, so consumers can see duplicates and ordering is not preserved.
+ /// Core NATS has no delayed delivery, so a non-zero requires a scheduler.
+ ///
+ /// The to requeue; must carry the original NATS message in its bag.
+ /// How long to wait before the message is redelivered; or republishes immediately.
+ /// Cancels the republish.
+ /// if the message was requeued or scheduled; if it did not carry a NATS message.
+ /// Thrown when a non-zero delay is requested but no scheduler is configured.
+ public async Task RequeueAsync(Message message, TimeSpan? delay = null,
+ CancellationToken cancellationToken = default)
+ {
+ delay ??= TimeSpan.Zero;
+ if (delay > TimeSpan.Zero)
+ {
+ if (scheduler is IAmAMessageSchedulerAsync asyncScheduler)
+ {
+ Log.SchedulingRequeue(s_logger, message.Id.Value, delay.Value);
+ await asyncScheduler.ScheduleAsync(message, delay.Value, cancellationToken);
+ return true;
+ }
+
+ if (scheduler is IAmAMessageSchedulerSync syncScheduler)
+ {
+ Log.SchedulingRequeue(s_logger, message.Id.Value, delay.Value);
+ syncScheduler.Schedule(message, delay.Value);
+ return true;
+ }
+
+ throw new ConfigurationException(
+ $"NatsMessageConsumer: requeue delay of {delay} was requested but no scheduler is configured; configure a scheduler via the channel factory's Scheduler property.");
+ }
+
+ if (!message.Header.Bag.TryGetValue(HeadersName.NatsMessage, out var n) || n is not NatsMsg natsMsg)
+ {
+ Log.CannotRequeueMessage(s_logger, message.Id.Value);
+ return false;
+ }
+
+ Log.RequeueingMessage(s_logger, message.Id.Value, message.Header.Topic.Value);
+ await client.PublishAsync(message.Header.Topic.Value,
+ natsMsg.Data,
+ natsMsg.Headers,
+ natsMsg.ReplyTo, cancellationToken: cancellationToken);
+
+ return true;
+ }
+
+ ///
+ public void Acknowledge(Message message)
+ {
+ }
+
+ ///
+ public bool Reject(Message message, MessageRejectionReason? reason = null)
+ {
+ return true;
+ }
+
+ ///
+ public void Purge()
+ {
+ }
+
+ ///
+ public Message[] Receive(TimeSpan? timeOut = null)
+ {
+ return BrighterAsyncContext.Run(async () => await ReceiveAsync(timeOut));
+ }
+
+ ///
+ public void Nack(Message message)
+ {
+ }
+
+ ///
+ public bool Requeue(Message message, TimeSpan? delay = null)
+ {
+ return BrighterAsyncContext.Run(async () => await RequeueAsync(message, delay));
+ }
+
+ ///
+ /// Disposes the underlying NATS subscription.
+ ///
+ public void Dispose()
+ {
+ BrighterAsyncContext.Run(async () => await DisposeAsync());
+ }
+
+ ///
+ /// Disposes the underlying NATS subscription.
+ ///
+ /// A representing the asynchronous operation.
+ public async ValueTask DisposeAsync()
+ {
+ await subscription.DisposeAsync();
+ }
+
+ private static partial class Log
+ {
+ [LoggerMessage(LogLevel.Debug, "No messages available on the NATS subscription before the timeout")]
+ public static partial void NoMessagesAvailable(ILogger logger);
+
+ [LoggerMessage(LogLevel.Debug, "Requeueing message {MessageId} by republishing to NATS subject {Subject}")]
+ public static partial void RequeueingMessage(ILogger logger, string messageId, string subject);
+
+ [LoggerMessage(LogLevel.Debug, "Scheduling requeue of message {MessageId} with delay {Delay}")]
+ public static partial void SchedulingRequeue(ILogger logger, string messageId, TimeSpan delay);
+
+ [LoggerMessage(LogLevel.Warning, "Cannot requeue message {MessageId}: the original NATS message is missing from the message bag")]
+ public static partial void CannotRequeueMessage(ILogger logger, string messageId);
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageConsumerFactory.cs b/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageConsumerFactory.cs
new file mode 100644
index 0000000000..d2ae2c2b80
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageConsumerFactory.cs
@@ -0,0 +1,186 @@
+// The MIT License (MIT)
+// Copyright © 2014 Ian Cooper
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using NATS.Client.Core;
+using NATS.Client.JetStream;
+using NATS.Client.JetStream.Models;
+using Paramore.Brighter.Logging;
+using Paramore.Brighter.Tasks;
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+///
+/// Creates Brighter message consumers for NATS subscriptions.
+///
+///
+/// A yields a over a core NATS subject;
+/// a yields a over a JetStream
+/// durable consumer. For stream subscriptions the JetStream stream and consumer are created, validated, or
+/// assumed to exist according to : the channel name is used as the
+/// stream name and the routing key as the stream subject unless
+/// provides an explicit stream configuration.
+///
+/// The used for core NATS subscriptions.
+/// The used for JetStream subscriptions.
+public partial class NatsMessageConsumerFactory(INatsClient natsClient, INatsJSContext natsJsContext) : IAmAMessageConsumerFactory
+{
+ private static readonly ILogger s_logger = ApplicationLogging.CreateLogger();
+
+ ///
+ /// Gets or sets the message scheduler used for delayed requeue on core NATS subscriptions.
+ /// Can be set after construction to allow channel factories to forward the scheduler from DI.
+ /// JetStream subscriptions use native NAK delays and do not need a scheduler.
+ ///
+ /// The , or when delayed requeue is not configured.
+ public IAmAMessageScheduler? Scheduler { get; set; }
+
+ ///
+ /// Creates a synchronous consumer for the given subscription.
+ ///
+ /// A or .
+ /// An for the subscription.
+ /// Thrown when is not a NATS subscription.
+ /// Thrown when the channel is validated or created but the JetStream stream or consumer cannot be found.
+ public IAmAMessageConsumerSync Create(Subscription subscription)
+ {
+ if (subscription is NatsSubscription natsSubscription)
+ {
+ return Create(natsSubscription);
+ }
+
+ if (subscription is NatsStreamSubscription natsStreamSubscription)
+ {
+ return BrighterAsyncContext.Run(async () => await CreateAsync(natsStreamSubscription));
+ }
+
+ throw new ConfigurationException("We expect a NatsSubscription or NatsStreamSubscription as a parameter");
+ }
+
+ ///
+ /// Creates an asynchronous consumer for the given subscription.
+ ///
+ /// A or .
+ /// An for the subscription.
+ /// Thrown when is not a NATS subscription.
+ /// Thrown when the channel is validated or created but the JetStream stream or consumer cannot be found.
+ public IAmAMessageConsumerAsync CreateAsync(Subscription subscription)
+ {
+ if (subscription is NatsSubscription natsSubscription)
+ {
+ return Create(natsSubscription);
+ }
+
+ if (subscription is NatsStreamSubscription natsStreamSubscription)
+ {
+ return BrighterAsyncContext.Run(async () => await CreateAsync(natsStreamSubscription));
+ }
+
+ throw new ConfigurationException("We expect a NatsSubscription or NatsStreamSubscription as a parameter");
+ }
+
+ private NatsMessageConsumer Create(NatsSubscription subscription)
+ {
+ Log.CreatingCoreConsumer(s_logger, subscription.ChannelName.Value, subscription.QueueGroup ?? "none");
+ var sub = BrighterAsyncContext.Run(async () => await natsClient.Connection.SubscribeCoreAsync(
+ subscription.ChannelName.Value,
+ subscription.QueueGroup,
+ opts: subscription.NatsSubOpts));
+
+ return new NatsMessageConsumer(sub, natsClient.Connection, Scheduler);
+ }
+
+ private async Task CreateAsync(NatsStreamSubscription subscription)
+ {
+ await EnsureExistsAsync(subscription);
+ Log.CreatingStreamConsumer(s_logger, subscription.ChannelName.Value, subscription.Consumer);
+ var stream = await natsJsContext.GetStreamAsync(subscription.ChannelName.Value);
+ var consumer = await stream.GetConsumerAsync(subscription.Consumer);
+ var stopTokenSource = new CancellationTokenSource();
+ var buffer = consumer.ConsumeAsync(opts: new NatsJSConsumeOpts
+ {
+ MaxMsgs = subscription.BufferSize,
+ IdleHeartbeat = subscription.IdleHeartbeat,
+ PriorityGroup = subscription.PriorityGroup,
+ }, cancellationToken: stopTokenSource.Token);
+
+ return new NatsStreamMessageConsumer(stream, buffer, stopTokenSource);
+ }
+
+ private async Task EnsureExistsAsync(NatsStreamSubscription subscription)
+ {
+ if (subscription.MakeChannels == OnMissingChannel.Assume)
+ {
+ return;
+ }
+
+ if (subscription.MakeChannels == OnMissingChannel.Validate)
+ {
+ try
+ {
+ var s = await natsJsContext.GetStreamAsync(subscription.ChannelName.Value);
+ _ = await s.GetConsumerAsync(subscription.Consumer);
+ }
+ catch (NatsJSApiException e) when (e.Error.Code == 404)
+ {
+ Log.StreamOrConsumerMissing(s_logger, subscription.ChannelName.Value, subscription.Consumer);
+ throw new ChannelFailureException(
+ $"Stream {subscription.ChannelName.Value} or consumer {subscription.Consumer} does not exist", e);
+ }
+
+ return;
+ }
+
+ var config = subscription.StreamConfiguration ?? new StreamConfig
+ {
+ Name = subscription.ChannelName.Value,
+ Subjects = [subscription.RoutingKey.Value]
+ };
+ Log.CreatingOrUpdatingStream(s_logger, config.Name ?? subscription.ChannelName.Value);
+ var stream = await natsJsContext.CreateOrUpdateStreamAsync(config);
+
+ if (subscription.Ordered)
+ {
+ await stream.CreateOrderedConsumerAsync(subscription.OrderedConsumerOption ?? new NatsJSOrderedConsumerOpts());
+ }
+ else
+ {
+ await stream.CreateOrUpdateConsumerAsync(subscription.ConsumerOption);
+ }
+ }
+
+ private static partial class Log
+ {
+ [LoggerMessage(LogLevel.Debug, "Creating core NATS consumer for subject {Subject} with queue group {QueueGroup}")]
+ public static partial void CreatingCoreConsumer(ILogger logger, string subject, string queueGroup);
+
+ [LoggerMessage(LogLevel.Debug, "Creating JetStream consumer for stream {StreamName} and consumer {ConsumerName}")]
+ public static partial void CreatingStreamConsumer(ILogger logger, string streamName, string consumerName);
+
+ [LoggerMessage(LogLevel.Warning, "JetStream stream {StreamName} or consumer {ConsumerName} does not exist")]
+ public static partial void StreamOrConsumerMissing(ILogger logger, string streamName, string consumerName);
+
+ [LoggerMessage(LogLevel.Debug, "Creating or updating JetStream stream {StreamName}")]
+ public static partial void CreatingOrUpdatingStream(ILogger logger, string streamName);
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageGatewayConfiguration.cs b/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageGatewayConfiguration.cs
new file mode 100644
index 0000000000..d05b6f8f25
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageGatewayConfiguration.cs
@@ -0,0 +1,79 @@
+// The MIT License (MIT)
+// Copyright © 2014 Ian Cooper
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+using NATS.Client.Core;
+using NATS.Client.JetStream;
+using Paramore.Brighter.Observability;
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+///
+/// Configuration for the NATS messaging gateway: connection options for core NATS and JetStream.
+///
+public class NatsMessageGatewayConfiguration
+{
+ ///
+ /// Initializes a new instance of the class with default options,
+ /// connecting to nats://localhost:4222.
+ ///
+ public NatsMessageGatewayConfiguration()
+ {
+
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The used to connect to the NATS server.
+ public NatsMessageGatewayConfiguration(NatsOpts natsOpts)
+ {
+ NatsOpts = natsOpts;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The used to connect to the NATS server.
+ /// The used for JetStream operations.
+ public NatsMessageGatewayConfiguration(NatsOpts natsOpts, NatsJSOpts natsJSOpts)
+ {
+ NatsOpts = natsOpts;
+ NatsJsOpts = natsJSOpts;
+ }
+
+ ///
+ /// Gets or sets the options used to connect to the NATS server.
+ ///
+ /// The ; defaults to a plain connection to nats://localhost:4222.
+ public NatsOpts NatsOpts { get; set; } = new NatsOpts();
+
+ ///
+ /// Gets or sets the options used for JetStream operations.
+ ///
+ /// The , or to derive defaults from .
+ public NatsJSOpts? NatsJsOpts { get; set; }
+
+ ///
+ /// Gets or sets how much telemetry the gateway writes.
+ ///
+ /// The ; defaults to .
+ public InstrumentationOptions Instrumentation { get; set; } = InstrumentationOptions.All;
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageProducer.cs b/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageProducer.cs
new file mode 100644
index 0000000000..2f9eb81eae
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageProducer.cs
@@ -0,0 +1,157 @@
+// The MIT License (MIT)
+// Copyright © 2014 Ian Cooper
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using NATS.Client.Core;
+using Paramore.Brighter.Logging;
+using Paramore.Brighter.MessagingGateway.NATS.Extensions;
+using Paramore.Brighter.Observability;
+using Paramore.Brighter.Tasks;
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+///
+/// Publishes Brighter messages to a core NATS subject for a .
+///
+///
+/// Core NATS provides at-most-once delivery: there is no broker-side persistence or acknowledgement.
+/// Delayed sends are not supported by the transport itself; delegates to the
+/// configured and throws a if none is set.
+///
+/// The used to publish.
+/// The describing where messages are published.
+/// The controlling how much telemetry is written.
+public partial class NatsMessageProducer(
+ INatsClient client,
+ NatsPublication publication,
+ InstrumentationOptions instrumentation) : IAmAMessageProducerAsync , IAmAMessageProducerSync
+{
+ private static readonly ILogger s_logger = ApplicationLogging.CreateLogger();
+
+ ///
+ public Publication Publication => publication;
+
+ ///
+ public Activity? Span { get; set; }
+
+ ///
+ public IAmAMessageScheduler? Scheduler { get; set; }
+
+ ///
+ /// Publishes the message to the publication's subject.
+ ///
+ /// The to send; its headers are written as NATS headers and its body as the payload.
+ /// Cancels the publish.
+ /// A representing the asynchronous operation.
+ public async Task SendAsync(Message message, CancellationToken cancellationToken = default)
+ {
+ BrighterTracer.WriteProducerEvent(Span, MessagingSystem.Nats, message, instrumentation);
+ Log.SendingMessageToSubject(s_logger, publication.Topic!.Value, message.Id.Value);
+ try
+ {
+ await client.PublishAsync(publication.Topic!.Value,
+ message.Body.ToByteArray(),
+ headers: message.Header.ToNatsHeaders(),
+ replyTo: message.Header.ReplyTo?.Value,
+ cancellationToken: cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ Log.ErrorSendingMessageToSubject(s_logger, ex, publication.Topic!.Value, message.Id.Value);
+ throw;
+ }
+ }
+
+ ///
+ /// Sends the message after the given delay, using the configured .
+ ///
+ /// Core NATS has no native delayed delivery; a zero delay sends immediately.
+ /// The to send.
+ /// How long to wait before sending; or sends immediately.
+ /// Cancels the operation.
+ /// A representing the asynchronous operation.
+ /// Thrown when is .
+ /// Thrown when a non-zero delay is requested but no is configured.
+ public async Task SendWithDelayAsync(Message message, TimeSpan? delay, CancellationToken cancellationToken = default)
+ {
+ if (message is null)
+ throw new ArgumentNullException(nameof(message));
+
+ delay ??= TimeSpan.Zero;
+ if (delay == TimeSpan.Zero)
+ {
+ await SendAsync(message, cancellationToken);
+ return;
+ }
+
+ if (Scheduler is IAmAMessageSchedulerAsync asyncScheduler)
+ {
+ await asyncScheduler.ScheduleAsync(message, delay.Value, cancellationToken);
+ return;
+ }
+
+ if (Scheduler is IAmAMessageSchedulerSync syncScheduler)
+ {
+ syncScheduler.Schedule(message, delay.Value);
+ return;
+ }
+
+ throw new ConfigurationException(
+ $"NatsMessageProducer: delay of {delay} was requested but no scheduler is configured; configure a scheduler via MessageSchedulerFactory.");
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ return new ValueTask();
+ }
+
+ ///
+ public void Dispose()
+ {
+
+ }
+
+ ///
+ public void Send(Message message)
+ {
+ BrighterAsyncContext.Run(async () => await SendAsync(message));
+ }
+
+ ///
+ public void SendWithDelay(Message message, TimeSpan? delay)
+ {
+ BrighterAsyncContext.Run(async () => await SendWithDelayAsync(message, delay));
+ }
+
+ private static partial class Log
+ {
+ [LoggerMessage(LogLevel.Debug, "Sending message {MessageId} to NATS subject {Subject}")]
+ public static partial void SendingMessageToSubject(ILogger logger, string subject, string messageId);
+
+ [LoggerMessage(LogLevel.Error, "Error sending message {MessageId} to NATS subject {Subject}")]
+ public static partial void ErrorSendingMessageToSubject(ILogger logger, Exception exception, string subject, string messageId);
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageProducerFactory.cs b/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageProducerFactory.cs
new file mode 100644
index 0000000000..55aa96ec19
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/NatsMessageProducerFactory.cs
@@ -0,0 +1,148 @@
+// The MIT License (MIT)
+// Copyright © 2014 Ian Cooper
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using NATS.Client.Core;
+using NATS.Client.JetStream;
+using NATS.Client.JetStream.Models;
+using Paramore.Brighter.Logging;
+using Paramore.Brighter.Observability;
+using Paramore.Brighter.Tasks;
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+///
+/// Creates Brighter message producers for a set of NATS publications.
+///
+///
+/// A yields a that persists messages
+/// in a JetStream stream; any other yields a over
+/// core NATS. For stream publications the JetStream stream is created, validated, or assumed to exist according to
+/// . When no explicit
+/// is provided, the stream is named after the publication
+/// topic with characters that are invalid in a stream name replaced by '-', and subscribes the topic as its only
+/// subject; consumers must use that same stream name as their channel name.
+///
+/// The used for core NATS publications.
+/// The used for JetStream publications.
+/// The set to create producers for.
+/// The controlling how much telemetry is written.
+public partial class NatsMessageProducerFactory(
+ INatsClient client,
+ INatsJSContext jsContext,
+ IEnumerable publications,
+ InstrumentationOptions instrumentation) : IAmAMessageProducerFactory
+{
+ private static readonly ILogger s_logger = ApplicationLogging.CreateLogger();
+
+ ///
+ /// Creates a producer for each publication, keyed by topic and request type.
+ ///
+ /// A of to .
+ /// Thrown when a stream publication is validated but its JetStream stream does not exist.
+ public Dictionary Create()
+ {
+ return BrighterAsyncContext.Run(async () => await CreateAsync());
+ }
+
+ ///
+ /// Asynchronously creates a producer for each publication, keyed by topic and request type.
+ ///
+ /// A of to .
+ /// Thrown when a stream publication is validated but its JetStream stream does not exist.
+ public async Task> CreateAsync()
+ {
+ var publicationsByTopic = new Dictionary();
+ foreach (var publication in publications)
+ {
+ if (RoutingKey.IsNullOrEmpty(publication.Topic))
+ {
+ continue;
+ }
+
+ if (publication is NatsStreamPublication streamPublication)
+ {
+ await EnsureExistsAsync(streamPublication);
+ Log.CreatingStreamProducer(s_logger, streamPublication.Topic!.Value);
+ publicationsByTopic[new ProducerKey(publication.Topic, publication.Type)] =
+ new NatsStreamMessageProducer(jsContext, streamPublication, instrumentation);
+ }
+ else
+ {
+ Log.CreatingCoreProducer(s_logger, publication.Topic!.Value);
+ publicationsByTopic[new ProducerKey(publication.Topic, publication.Type)] =
+ new NatsMessageProducer(client, publication, instrumentation);
+ }
+ }
+
+ return publicationsByTopic;
+ }
+
+
+ private async Task EnsureExistsAsync(NatsStreamPublication publication)
+ {
+ if (publication.MakeChannels == OnMissingChannel.Assume)
+ {
+ return;
+ }
+
+ if (publication.MakeChannels == OnMissingChannel.Validate)
+ {
+ try
+ {
+ _ = await jsContext.GetStreamAsync(publication.StreamConfiguration?.Name ?? NatsNameSanitizer.Sanitize(publication.Topic!.Value));
+ }
+ catch (NatsJSApiException e) when (e.Error.Code == 404)
+ {
+ Log.StreamMissing(s_logger, publication.Topic!.Value);
+ throw new ChannelFailureException(
+ $"Stream for topic {publication.Topic!.Value} does not exist", e);
+ }
+
+ return;
+ }
+
+ var config = publication.StreamConfiguration ?? new StreamConfig
+ {
+ Name = NatsNameSanitizer.Sanitize(publication.Topic!.Value),
+ Subjects = [publication.Topic!.Value]
+ };
+ Log.CreatingOrUpdatingStream(s_logger, config.Name ?? publication.Topic!.Value);
+ await jsContext.CreateOrUpdateStreamAsync(config);
+ }
+
+ private static partial class Log
+ {
+ [LoggerMessage(LogLevel.Debug, "Creating core NATS producer for subject {Subject}")]
+ public static partial void CreatingCoreProducer(ILogger logger, string subject);
+
+ [LoggerMessage(LogLevel.Debug, "Creating JetStream producer for subject {Subject}")]
+ public static partial void CreatingStreamProducer(ILogger logger, string subject);
+
+ [LoggerMessage(LogLevel.Warning, "JetStream stream for topic {Topic} does not exist")]
+ public static partial void StreamMissing(ILogger logger, string topic);
+
+ [LoggerMessage(LogLevel.Debug, "Creating or updating JetStream stream {StreamName}")]
+ public static partial void CreatingOrUpdatingStream(ILogger logger, string streamName);
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/NatsNameSanitizer.cs b/src/Paramore.Brighter.MessagingGateway.NATS/NatsNameSanitizer.cs
new file mode 100644
index 0000000000..d4e37d46d7
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/NatsNameSanitizer.cs
@@ -0,0 +1,46 @@
+#region Licence
+
+/* The MIT License (MIT)
+Copyright © 2026 Rafael Andrade
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the “Software”), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE. */
+
+#endregion
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+// JetStream stream and durable consumer names appear in API subjects, so they cannot contain
+// whitespace, '.', '*', '>', or path separators. Producers and consumers must derive the same
+// name from the same topic or type, so the rule lives here, shared by both sides.
+internal static class NatsNameSanitizer
+{
+ public static string Sanitize(string name)
+ {
+ var chars = name.ToCharArray();
+ for (var i = 0; i < chars.Length; i++)
+ {
+ if (!char.IsLetterOrDigit(chars[i]) && chars[i] != '-' && chars[i] != '_')
+ {
+ chars[i] = '-';
+ }
+ }
+
+ return new string(chars);
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/NatsProducerRegistryFactory.cs b/src/Paramore.Brighter.MessagingGateway.NATS/NatsProducerRegistryFactory.cs
new file mode 100644
index 0000000000..968297f51c
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/NatsProducerRegistryFactory.cs
@@ -0,0 +1,63 @@
+// The MIT License (MIT)
+// Copyright © 2014 Ian Cooper
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using NATS.Client.JetStream;
+using NATS.Net;
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+///
+/// Creates a of NATS message producers from a set of publications.
+///
+/// The used to connect to NATS.
+/// The set to create producers for.
+public class NatsProducerRegistryFactory(
+ NatsMessageGatewayConfiguration configuration,
+ IEnumerable publications) : IAmAProducerRegistryFactory
+{
+ ///
+ /// Creates the producer registry, opening a NATS connection and a JetStream context.
+ ///
+ /// An holding one producer per publication.
+ /// Thrown when a stream publication is validated but its JetStream stream does not exist.
+ public IAmAProducerRegistry Create()
+ {
+ var natsClient = new NatsClient(configuration.NatsOpts);
+ var jsClient = natsClient.CreateJetStreamContext(configuration.NatsJsOpts ?? new NatsJSOpts(configuration.NatsOpts));
+ var producerFactory = new NatsMessageProducerFactory(natsClient, jsClient, publications, configuration.Instrumentation);
+
+ return new ProducerRegistry(producerFactory.Create());
+ }
+
+ ///
+ /// Creates the producer registry, opening a NATS connection and a JetStream context.
+ ///
+ /// Cancels the operation.
+ /// An holding one producer per publication.
+ /// Thrown when a stream publication is validated but its JetStream stream does not exist.
+ public Task CreateAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(Create());
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/NatsPublication.cs b/src/Paramore.Brighter.MessagingGateway.NATS/NatsPublication.cs
new file mode 100644
index 0000000000..ec1144bc78
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/NatsPublication.cs
@@ -0,0 +1,49 @@
+#region Licence
+/* The MIT License (MIT)
+Copyright © 2026 Ian Cooper
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the “Software”), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE. */
+#endregion
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+///
+/// Publication configuration for publishing to a core NATS subject.
+///
+///
+/// The publication topic is used as the NATS subject. Core NATS is at-most-once; use
+/// to persist messages in a JetStream stream.
+///
+public class NatsPublication : Publication;
+
+///
+/// Represents a publication for NATS, associating a specific message type with the publication.
+///
+/// The type of request that this publication handles.
+public class NatsPublication : NatsPublication
+ where TRequest : class, IRequest
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public NatsPublication()
+ {
+ RequestType = typeof(TRequest);
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/NatsStreamMessageConsumer.cs b/src/Paramore.Brighter.MessagingGateway.NATS/NatsStreamMessageConsumer.cs
new file mode 100644
index 0000000000..fc3823d2e7
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/NatsStreamMessageConsumer.cs
@@ -0,0 +1,318 @@
+// The MIT License (MIT)
+// Copyright © 2014 Ian Cooper
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using NATS.Client.JetStream;
+using NATS.Client.JetStream.Models;
+using Paramore.Brighter.Logging;
+using Paramore.Brighter.MessagingGateway.NATS.Extensions;
+using Paramore.Brighter.Tasks;
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+///
+/// Consumes messages from a NATS JetStream consumer on behalf of a .
+///
+///
+///
+/// Messages are read one at a time from a single continuous consume loop
+/// ()
+/// created by . The enumerator is kept alive for the lifetime of this
+/// consumer: disposing an enumerator ends the underlying JetStream pull subscription, so a fresh one per receive
+/// would stop delivery after the first message. A timed-out receive leaves the pending read in place for the
+/// next call rather than cancelling it.
+///
+///
+/// The original JetStream message travels in the Brighter message bag under
+/// and is required for any acknowledgement operation; if it is absent the operation is a no-op.
+/// Acknowledgement semantics follow JetStream: sends +ACK,
+/// sends +TERM so a rejected message is never redelivered, and
+/// sends -NAK, optionally with a redelivery delay, so the message is redelivered.
+///
+///
+/// Terminal consume-loop errors (for example the consumer being deleted, or a permanent connection failure)
+/// surface as exceptions from ; the consume loop does not recover by itself, so the
+/// channel must be recreated to resume delivery.
+///
+///
+/// The the subscription consumes from; used to purge the stream.
+/// The continuous stream of pulled from the JetStream consumer.
+/// A that stops when the consumer is disposed.
+public partial class NatsStreamMessageConsumer(
+ INatsJSStream stream,
+ IAsyncEnumerable> messagesBuffer,
+ CancellationTokenSource stopTokenSource) : IAmAMessageConsumerAsync, IAmAMessageConsumerSync
+{
+ private static readonly ILogger s_logger = ApplicationLogging.CreateLogger();
+
+ private IAsyncEnumerator>? _enumerator;
+ private Task? _pendingMoveNext;
+
+ ///
+ /// Acknowledges the message by sending +ACK to JetStream, removing it from the consumer's pending list.
+ ///
+ /// The to acknowledge; must carry the JetStream message in its bag.
+ /// Cancels the acknowledgement.
+ /// A representing the asynchronous operation.
+ public async Task AcknowledgeAsync(Message message, CancellationToken cancellationToken = default)
+ {
+ if (!TryGetNatsMessage(message, out var natsMsg))
+ {
+ Log.CannotAcknowledgeMessage(s_logger, message.Id.Value);
+ return;
+ }
+
+ Log.AcknowledgingMessage(s_logger, message.Id.Value);
+ await natsMsg.AckAsync(cancellationToken: cancellationToken);
+ }
+
+ ///
+ /// Rejects the message by sending +TERM to JetStream, so it is never redelivered.
+ ///
+ /// The to reject; must carry the JetStream message in its bag.
+ /// The that explains why we rejected the message; sent to the server as the termination reason. Requires NATS Server 2.10.4+.
+ /// Cancels the rejection.
+ /// if the message carried a JetStream message and was terminated; otherwise .
+ public async Task RejectAsync(Message message, MessageRejectionReason? reason = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (!TryGetNatsMessage(message, out var natsMsg))
+ {
+ Log.CannotRejectMessage(s_logger, message.Id.Value);
+ return false;
+ }
+
+ Log.RejectingMessage(s_logger, message.Id.Value,
+ reason?.Description ?? reason?.RejectionReason.ToString() ?? "unspecified");
+ await natsMsg.AckTerminateAsync(
+ new AckOpts { TerminateReason = reason?.Description ?? reason?.RejectionReason.ToString() },
+ cancellationToken);
+ return true;
+ }
+
+ ///
+ /// Purges all messages from the JetStream stream backing this subscription.
+ ///
+ /// Cancels the purge.
+ /// A representing the asynchronous operation.
+ public async Task PurgeAsync(CancellationToken cancellationToken = default)
+ {
+ Log.PurgingStream(s_logger, stream.Info.Config.Name ?? string.Empty);
+ await stream.PurgeAsync(new StreamPurgeRequest(), cancellationToken);
+ }
+
+ ///
+ /// Receives the next message from the JetStream consumer, waiting up to .
+ ///
+ /// How long to wait for a message; if omitted, waits until is cancelled.
+ /// Cancels the wait.
+ /// An array with the received , or an empty if none arrived before the timeout.
+ public async Task ReceiveAsync(TimeSpan? timeOut = null, CancellationToken cancellationToken = default)
+ {
+ _enumerator ??= messagesBuffer.GetAsyncEnumerator(stopTokenSource.Token);
+
+ var next = _enumerator.MoveNextAsync();
+ if (next.IsCompleted)
+ {
+ return [_enumerator.Current.ToMessage()];
+ }
+
+ _pendingMoveNext ??= next.AsTask();
+
+ var delay = Task.Delay(timeOut ?? Timeout.InfiniteTimeSpan, cancellationToken);
+ var completed = await Task.WhenAny(_pendingMoveNext, delay).ConfigureAwait(false);
+
+ if (completed != _pendingMoveNext)
+ {
+ // Timed out or cancelled; keep the pending read for the next call instead of abandoning it.
+ Log.NoMessagesAvailable(s_logger);
+ return [new Message()];
+ }
+
+ var moveNext = _pendingMoveNext;
+ _pendingMoveNext = null;
+
+ try
+ {
+ if (await moveNext.ConfigureAwait(false))
+ {
+ return [_enumerator.Current.ToMessage()];
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // The consumer is being disposed mid-read.
+ }
+
+ return [new Message()];
+ }
+
+ ///
+ /// Negative-acknowledges the message by sending -NAK to JetStream, triggering redelivery.
+ ///
+ /// The to nack; must carry the JetStream message in its bag.
+ /// Cancels the nack.
+ /// A representing the asynchronous operation.
+ public async Task NackAsync(Message message, CancellationToken cancellationToken = default)
+ {
+ if (!TryGetNatsMessage(message, out var natsMsg))
+ {
+ Log.CannotNackMessage(s_logger, message.Id.Value);
+ return;
+ }
+
+ Log.NackingMessage(s_logger, message.Id.Value);
+ await natsMsg.NakAsync(cancellationToken: cancellationToken);
+ }
+
+ ///
+ /// Requeues the message by sending -NAK to JetStream, so it is redelivered.
+ ///
+ /// The to requeue; must carry the JetStream message in its bag.
+ /// How long JetStream should wait before redelivering; if omitted, redelivers immediately.
+ /// Cancels the requeue.
+ /// if the message carried a JetStream message and was requeued; otherwise .
+ public async Task RequeueAsync(Message message, TimeSpan? delay = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (!TryGetNatsMessage(message, out var natsMsg))
+ {
+ Log.CannotRequeueMessage(s_logger, message.Id.Value);
+ return false;
+ }
+
+ Log.RequeueingMessage(s_logger, message.Id.Value, delay);
+ var opts = delay.HasValue ? new AckOpts { NakDelay = delay.Value } : (AckOpts?)null;
+ await natsMsg.NakAsync(opts, cancellationToken);
+ return true;
+ }
+
+ ///
+ /// Stops the JetStream consume loop feeding this consumer.
+ ///
+ /// A representing the asynchronous operation.
+ public async ValueTask DisposeAsync()
+ {
+ stopTokenSource.Cancel();
+ if (_enumerator is not null)
+ {
+ await _enumerator.DisposeAsync();
+ }
+
+ stopTokenSource.Dispose();
+ }
+
+ ///
+ public void Dispose()
+ {
+ BrighterAsyncContext.Run(async () => await DisposeAsync());
+ }
+
+ ///
+ public void Acknowledge(Message message)
+ {
+ BrighterAsyncContext.Run(async () => await AcknowledgeAsync(message));
+ }
+
+ ///
+ public bool Reject(Message message, MessageRejectionReason? reason = null)
+ {
+ return BrighterAsyncContext.Run(async () => await RejectAsync(message, reason));
+ }
+
+ ///
+ public void Purge()
+ {
+ BrighterAsyncContext.Run(async () => await PurgeAsync());
+ }
+
+ ///
+ public Message[] Receive(TimeSpan? timeOut = null)
+ {
+ return BrighterAsyncContext.Run(async () => await ReceiveAsync(timeOut));
+ }
+
+ ///
+ public void Nack(Message message)
+ {
+ BrighterAsyncContext.Run(async () => await NackAsync(message));
+ }
+
+ ///
+ public bool Requeue(Message message, TimeSpan? delay = null)
+ {
+ return BrighterAsyncContext.Run(async () => await RequeueAsync(message, delay));
+ }
+
+ private static bool TryGetNatsMessage(Message message, out INatsJSMsg natsMsg)
+ {
+ if (message.Header.Bag.TryGetValue(HeadersName.NatsMessage, out var obj)
+ && obj is INatsJSMsg jsMsg)
+ {
+ natsMsg = jsMsg;
+ return true;
+ }
+
+ natsMsg = null!;
+ return false;
+ }
+
+ private static partial class Log
+ {
+ [LoggerMessage(LogLevel.Debug, "Acknowledging message {MessageId} (+ACK)")]
+ public static partial void AcknowledgingMessage(ILogger logger, string messageId);
+
+ [LoggerMessage(LogLevel.Warning,
+ "Cannot acknowledge message {MessageId}: the JetStream message is missing from the message bag")]
+ public static partial void CannotAcknowledgeMessage(ILogger logger, string messageId);
+
+ [LoggerMessage(LogLevel.Debug, "Rejecting message {MessageId} (+TERM) with reason {Reason}")]
+ public static partial void RejectingMessage(ILogger logger, string messageId, string reason);
+
+ [LoggerMessage(LogLevel.Warning,
+ "Cannot reject message {MessageId}: the JetStream message is missing from the message bag")]
+ public static partial void CannotRejectMessage(ILogger logger, string messageId);
+
+ [LoggerMessage(LogLevel.Debug, "Nacking message {MessageId} (-NAK)")]
+ public static partial void NackingMessage(ILogger logger, string messageId);
+
+ [LoggerMessage(LogLevel.Warning,
+ "Cannot nack message {MessageId}: the JetStream message is missing from the message bag")]
+ public static partial void CannotNackMessage(ILogger logger, string messageId);
+
+ [LoggerMessage(LogLevel.Debug, "Requeueing message {MessageId} (-NAK) with redelivery delay {Delay}")]
+ public static partial void RequeueingMessage(ILogger logger, string messageId, TimeSpan? delay);
+
+ [LoggerMessage(LogLevel.Warning,
+ "Cannot requeue message {MessageId}: the JetStream message is missing from the message bag")]
+ public static partial void CannotRequeueMessage(ILogger logger, string messageId);
+
+ [LoggerMessage(LogLevel.Debug, "No messages available on the JetStream consumer before the timeout")]
+ public static partial void NoMessagesAvailable(ILogger logger);
+
+ [LoggerMessage(LogLevel.Information, "Purging all messages from JetStream stream {StreamName}")]
+ public static partial void PurgingStream(ILogger logger, string streamName);
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/NatsStreamMessageProducer.cs b/src/Paramore.Brighter.MessagingGateway.NATS/NatsStreamMessageProducer.cs
new file mode 100644
index 0000000000..2fb8a5e696
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/NatsStreamMessageProducer.cs
@@ -0,0 +1,194 @@
+// The MIT License (MIT)
+// Copyright © 2014 Ian Cooper
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using NATS.Client.JetStream;
+using Paramore.Brighter.Logging;
+using Paramore.Brighter.MessagingGateway.NATS.Extensions;
+using Paramore.Brighter.Observability;
+using Paramore.Brighter.Tasks;
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+///
+/// Publishes Brighter messages to a NATS JetStream stream for a .
+///
+///
+/// Publishing through JetStream persists the message in the stream, giving at-least-once delivery to stream
+/// consumers. The server's pub-ack is verified on every send and doubles as the publish confirmation:
+/// is raised with the outcome so the Outbox mediator only marks a message
+/// dispatched once the server has stored it. Delayed sends are not supported by the transport itself;
+/// delegates to the configured and throws a
+/// if none is set.
+///
+/// The used to publish to the stream.
+/// The describing where messages are published.
+/// The controlling how much telemetry is written.
+public partial class NatsStreamMessageProducer(
+ INatsJSContext jsContext,
+ NatsStreamPublication publication,
+ InstrumentationOptions instrumentations) : IAmAMessageProducerAsync, IAmAMessageProducerSync, ISupportPublishConfirmation
+{
+ private static readonly ILogger s_logger = ApplicationLogging.CreateLogger();
+
+ ///
+ /// Action taken when a message is published, following receipt of the JetStream pub-ack from the server.
+ /// See https://www.rabbitmq.com/blog/2011/02/10/introducing-publisher-confirms#how-confirms-work for more.
+ ///
+ public event Action? OnMessagePublished;
+
+ ///
+ public Publication Publication => publication;
+
+ ///
+ public Activity? Span { get; set; }
+
+ ///
+ public IAmAMessageScheduler? Scheduler { get; set; }
+
+ ///
+ /// Publishes the message to the publication's subject, persisting it in the JetStream stream.
+ ///
+ /// Verifies the server's pub-ack so server-side failures (such as no stream capturing the subject) are not silently swallowed; the ack outcome is raised as the publish confirmation.
+ /// The to send; its headers are written as NATS headers and its body as the payload.
+ /// Cancels the publish.
+ /// A representing the asynchronous operation.
+ /// Thrown when the JetStream server responds that the message was not stored.
+ public async Task SendAsync(Message message, CancellationToken cancellationToken = default)
+ {
+ BrighterTracer.WriteProducerEvent(Span, MessagingSystem.Nats, message, instrumentations);
+
+ // Capture the publish span context synchronously, before any continuation runs, so the
+ // confirmation can be linked back to the original publish.
+ var publishContext = Activity.Current?.Context;
+ Log.SendingMessageToStream(s_logger, publication.Topic!.Value, message.Id.Value);
+ try
+ {
+ var ack = await jsContext.PublishAsync(publication.Topic!.Value, message.Body.ToByteArray(),
+ headers: message.Header.ToNatsHeaders(),
+ cancellationToken: cancellationToken);
+ ack.EnsureSuccess();
+
+ RaisePublishConfirmation(new PublishConfirmationResult(true, message.Id, publication.Topic, publishContext));
+ }
+ catch (NatsJSApiException ex)
+ {
+ Log.ErrorSendingMessageToStream(s_logger, ex, publication.Topic!.Value, message.Id.Value);
+ RaisePublishConfirmation(new PublishConfirmationResult(false, message.Id, publication.Topic, publishContext));
+ throw new ChannelFailureException("JetStream server rejected the publish, see inner exception for details", ex);
+ }
+ }
+
+ ///
+ /// Sends the message after the given delay, using the configured .
+ ///
+ /// JetStream has no native delayed delivery; a zero delay sends immediately.
+ /// The to send.
+ /// How long to wait before sending; or sends immediately.
+ /// Cancels the operation.
+ /// A representing the asynchronous operation.
+ /// Thrown when is .
+ /// Thrown when a non-zero delay is requested but no is configured.
+ public async Task SendWithDelayAsync(Message message, TimeSpan? delay, CancellationToken cancellationToken = default)
+ {
+ if (message is null)
+ throw new ArgumentNullException(nameof(message));
+
+ delay ??= TimeSpan.Zero;
+ if (delay == TimeSpan.Zero)
+ {
+ await SendAsync(message, cancellationToken);
+ return;
+ }
+
+ if (Scheduler is IAmAMessageSchedulerAsync asyncScheduler)
+ {
+ await asyncScheduler.ScheduleAsync(message, delay.Value, cancellationToken);
+ return;
+ }
+
+ if (Scheduler is IAmAMessageSchedulerSync syncScheduler)
+ {
+ syncScheduler.Schedule(message, delay.Value);
+ return;
+ }
+
+ throw new ConfigurationException(
+ $"NatsStreamMessageProducer: delay of {delay} was requested but no scheduler is configured; configure a scheduler via MessageSchedulerFactory.");
+ }
+
+ ///
+ public void Send(Message message)
+ {
+ BrighterAsyncContext.Run(async () => await SendAsync(message));
+ }
+
+ ///
+ public void SendWithDelay(Message message, TimeSpan? delay)
+ {
+ BrighterAsyncContext.Run(async () => await SendWithDelayAsync(message, delay));
+ }
+
+ ///
+ public void Dispose()
+ {
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ return new ValueTask();
+ }
+
+ private void RaisePublishConfirmation(PublishConfirmationResult result)
+ {
+ // Raise on a worker thread so we never block the caller (which may be the single-threaded
+ // BrighterAsyncContext on the sync path). Wrap the invoke so a faulting subscriber is logged
+ // rather than left as an unobserved Task exception.
+ _ = Task.Run(() =>
+ {
+ try
+ {
+ OnMessagePublished?.Invoke(result);
+ }
+ catch (Exception ex)
+ {
+ Log.PublishConfirmationRaiseFault(s_logger, ex);
+ }
+ });
+ }
+
+ private static partial class Log
+ {
+ [LoggerMessage(LogLevel.Debug, "Sending message {MessageId} to JetStream subject {Subject}")]
+ public static partial void SendingMessageToStream(ILogger logger, string subject, string messageId);
+
+ [LoggerMessage(LogLevel.Error, "JetStream server rejected the publish of message {MessageId} to subject {Subject}")]
+ public static partial void ErrorSendingMessageToStream(ILogger logger, Exception exception, string subject, string messageId);
+
+ [LoggerMessage(LogLevel.Warning, "A publish-confirmation subscriber threw while handling a NATS JetStream pub-ack; the fault was contained")]
+ public static partial void PublishConfirmationRaiseFault(ILogger logger, Exception exception);
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/NatsStreamPublication.cs b/src/Paramore.Brighter.MessagingGateway.NATS/NatsStreamPublication.cs
new file mode 100644
index 0000000000..ba9795f76a
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/NatsStreamPublication.cs
@@ -0,0 +1,58 @@
+// The MIT License (MIT)
+// Copyright © 2014 Ian Cooper
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+using NATS.Client.JetStream.Models;
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+///
+/// Publication configuration for publishing to a NATS JetStream stream, persisting messages for at-least-once delivery.
+///
+///
+/// The publication topic is used as the subject the stream subscribes to. Unless
+/// provides an explicit stream, a stream named after the topic (with characters
+/// invalid in a stream name replaced by '-') is created, validated, or assumed according to
+/// .
+///
+public class NatsStreamPublication : NatsPublication
+{
+ ///
+ /// Gets or sets the explicit stream configuration used when the stream is created.
+ ///
+ /// The , or to derive one from the publication topic.
+ public StreamConfig? StreamConfiguration { get; set; }
+}
+
+///
+/// Represents a JetStream publication for NATS, associating a specific message type with the publication.
+///
+/// The type of request that this publication handles.
+public class NatsStreamPublication : NatsStreamPublication
+ where TRequest : class, IRequest
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public NatsStreamPublication()
+ {
+ RequestType = typeof(TRequest);
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/NatsStreamSubscription.cs b/src/Paramore.Brighter.MessagingGateway.NATS/NatsStreamSubscription.cs
new file mode 100644
index 0000000000..455ab121cc
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/NatsStreamSubscription.cs
@@ -0,0 +1,258 @@
+#region Licence
+
+/* The MIT License (MIT)
+Copyright © 2026 Ian Cooper
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the “Software”), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE. */
+
+#endregion
+
+using System;
+using NATS.Client.JetStream;
+using NATS.Client.JetStream.Models;
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+///
+/// Subscription configuration for consuming from a NATS JetStream stream with at-least-once delivery.
+///
+///
+/// The channel name is used as the JetStream stream name and the routing key as the stream subject when the
+/// stream is created; names the durable consumer within the stream. Use
+/// and to take full control of the JetStream
+/// resources created when allows creation.
+///
+public class NatsStreamSubscription : Subscription, IUseBrighterDeadLetterSupport, IUseBrighterInvalidMessageSupport
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The of the subscription.
+ /// The ; used as the JetStream stream name.
+ /// The ; used as the subject the stream subscribes to.
+ /// The name of the JetStream consumer; used as both the consumer name and durable name unless says otherwise.
+ /// The of the request carried by messages, if fixed.
+ /// A that resolves the request from a , if it varies.
+ /// The number of messages to buffer for the channel; defaults to 1.
+ /// The number of threads pumping this channel; defaults to 1.
+ /// The to wait for a message before treating the channel as empty.
+ /// How many times a message is requeued before it is rejected; -1 for unlimited.
+ /// The to wait before requeuing a failed message.
+ /// How many unacceptable messages to tolerate before stopping the channel; 0 for unlimited.
+ /// The ; whether the pump reads messages synchronously or asynchronously.
+ /// An optional used to create the channel infrastructure.
+ /// The policy for missing infrastructure.
+ /// The to wait when the channel is empty.
+ /// The to wait when the channel fails.
+ /// The window the unacceptable message limit applies to.
+ /// Whether to consume in strict order via a JetStream ordered consumer; defaults to .
+ /// Optional for the ordered consumer; only used when is .
+ /// Optional idle heartbeat for the consume loop.
+ /// Optional for prioritized pull consumption.
+ /// Optional explicit used to create the stream; when omitted, a stream named after subscribing is created.
+ /// Optional explicit used to create the consumer; when omitted, a durable consumer named after is created.
+ /// Optional of the dead-letter channel for rejected messages.
+ /// Optional of the channel for messages that cannot be deserialized.
+ public NatsStreamSubscription(
+ SubscriptionName subscriptionName,
+ ChannelName channelName,
+ RoutingKey routingKey,
+ string consumer,
+ Type? requestType = null,
+ Func? getRequestType = null,
+ int bufferSize = 1, int noOfPerformers = 1, TimeSpan? timeOut = null,
+ int requeueCount = -1, TimeSpan? requeueDelay = null, int unacceptableMessageLimit = 0,
+ MessagePumpType messagePumpType = MessagePumpType.Unknown, IAmAChannelFactory? channelFactory = null,
+ OnMissingChannel makeChannels = OnMissingChannel.Create, TimeSpan? emptyChannelDelay = null,
+ TimeSpan? channelFailureDelay = null, TimeSpan? unacceptableMessageLimitWindow = null,
+ bool ordered = false,
+ NatsJSOrderedConsumerOpts? orderedConsumerOption = null,
+ TimeSpan? idleHeartbeat = null,
+ NatsJSPriorityGroupOpts? priorityGroup = null,
+ StreamConfig? streamConfiguration = null,
+ ConsumerConfig? consumerOption = null,
+ RoutingKey? deadLetterRoutingKey = null,
+ RoutingKey? invalidMessageRoutingKey = null)
+ : base(subscriptionName, channelName, routingKey, requestType, getRequestType, bufferSize, noOfPerformers,
+ timeOut, requeueCount, requeueDelay, unacceptableMessageLimit, messagePumpType, channelFactory,
+ makeChannels, emptyChannelDelay, channelFailureDelay, unacceptableMessageLimitWindow)
+ {
+ Consumer = consumer;
+ Ordered = ordered;
+ OrderedConsumerOption = orderedConsumerOption;
+ IdleHeartbeat = idleHeartbeat;
+ PriorityGroup = priorityGroup ?? new NatsJSPriorityGroupOpts();
+ StreamConfiguration = streamConfiguration;
+ ConsumerOption = consumerOption ?? new ConsumerConfig { Name = consumer, DurableName = consumer };
+ DeadLetterRoutingKey = deadLetterRoutingKey;
+ InvalidMessageRoutingKey = invalidMessageRoutingKey;
+ }
+
+ ///
+ /// Gets the name of the JetStream consumer within the stream.
+ ///
+ /// The consumer name as a .
+ public string Consumer { get; }
+
+ ///
+ /// Gets a value indicating whether messages are consumed in strict order via a JetStream ordered consumer.
+ ///
+ /// when an ordered consumer is used; otherwise .
+ public bool Ordered { get; }
+
+ ///
+ /// Gets the options for the ordered consumer.
+ ///
+ /// The , or to use the client defaults; only used when is .
+ public NatsJSOrderedConsumerOpts? OrderedConsumerOption { get; }
+
+ ///
+ /// Gets the idle heartbeat for the consume loop.
+ ///
+ /// The idle heartbeat , or to use the client default.
+ public TimeSpan? IdleHeartbeat { get; }
+
+ ///
+ /// Gets the priority group options for prioritized pull consumption.
+ ///
+ /// The ; never .
+ public NatsJSPriorityGroupOpts PriorityGroup { get; }
+
+ ///
+ /// Gets the explicit stream configuration used when the stream is created.
+ ///
+ /// The , or to derive one from the channel name and routing key.
+ public StreamConfig? StreamConfiguration { get; }
+
+ ///
+ /// Gets the consumer configuration used when the consumer is created.
+ ///
+ /// The ; defaults to a durable consumer named after .
+ public ConsumerConfig ConsumerOption { get; }
+
+ ///
+ /// Gets or sets the routing key of the dead-letter channel for rejected messages.
+ ///
+ /// The dead-letter , or when dead-lettering is not configured.
+ public RoutingKey? DeadLetterRoutingKey { get; set; }
+
+ ///
+ /// Gets or sets the routing key of the channel for messages that cannot be deserialized.
+ ///
+ /// The invalid-message , or when not configured.
+ public RoutingKey? InvalidMessageRoutingKey { get; set; }
+}
+
+///
+/// Subscription configuration for consuming from a NATS JetStream stream, typed to a specific request.
+///
+///
+/// Defaults the subscription name and routing key (the stream subject) to the full name of
+/// . Because JetStream stream and durable consumer names cannot contain
+/// '.', whitespace, or wildcards, the channel name (the stream name) and consumer name default to the
+/// full name with other characters replaced by '-' — the same rule
+/// uses, so a generic publication and a generic subscription of the same request type meet on one stream.
+///
+/// The type of request that this subscription handles; must implement .
+public class NatsStreamSubscription : NatsStreamSubscription
+ where TRequest : class, IRequest
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The of the subscription. Optional; defaults to the full name of .
+ /// The ; used as the JetStream stream name. Optional; defaults to the full name of with characters invalid in a stream name replaced by '-'.
+ /// The ; used as the subject the stream subscribes to. Optional; defaults to the full name of .
+ /// The name of the JetStream consumer; used as both the consumer name and durable name unless says otherwise. Optional; defaults to the full name of with characters invalid in a consumer name replaced by '-'.
+ /// A that resolves the request from a ; optional, defaults to always returning .
+ /// The number of messages to buffer for the channel; defaults to 1.
+ /// The number of threads pumping this channel; defaults to 1.
+ /// The to wait for a message before treating the channel as empty.
+ /// How many times a message is requeued before it is rejected; -1 for unlimited.
+ /// The to wait before requeuing a failed message.
+ /// How many unacceptable messages to tolerate before stopping the channel; 0 for unlimited.
+ /// The ; whether the pump reads messages synchronously or asynchronously.
+ /// An optional used to create the channel infrastructure.
+ /// The policy for missing infrastructure.
+ /// The to wait when the channel is empty.
+ /// The to wait when the channel fails.
+ /// The window the unacceptable message limit applies to.
+ /// Whether to consume in strict order via a JetStream ordered consumer; defaults to .
+ /// Optional for the ordered consumer; only used when is .
+ /// Optional idle heartbeat for the consume loop.
+ /// Optional for prioritized pull consumption.
+ /// Optional explicit used to create the stream; when omitted, a stream named after subscribing is created.
+ /// Optional explicit used to create the consumer; when omitted, a durable consumer named after is created.
+ /// Optional of the dead-letter channel for rejected messages.
+ /// Optional of the channel for messages that cannot be deserialized.
+ public NatsStreamSubscription(
+ SubscriptionName? subscriptionName = null,
+ ChannelName? channelName = null,
+ RoutingKey? routingKey = null,
+ string? consumer = null,
+ Func? getRequestType = null,
+ int bufferSize = 1,
+ int noOfPerformers = 1,
+ TimeSpan? timeOut = null,
+ int requeueCount = -1,
+ TimeSpan? requeueDelay = null,
+ int unacceptableMessageLimit = 0,
+ MessagePumpType messagePumpType = MessagePumpType.Unknown,
+ IAmAChannelFactory? channelFactory = null,
+ OnMissingChannel makeChannels = OnMissingChannel.Create,
+ TimeSpan? emptyChannelDelay = null,
+ TimeSpan? channelFailureDelay = null,
+ TimeSpan? unacceptableMessageLimitWindow = null,
+ bool ordered = false,
+ NatsJSOrderedConsumerOpts? orderedConsumerOption = null,
+ TimeSpan? idleHeartbeat = null,
+ NatsJSPriorityGroupOpts? priorityGroup = null,
+ StreamConfig? streamConfiguration = null,
+ ConsumerConfig? consumerOption = null,
+ RoutingKey? deadLetterRoutingKey = null,
+ RoutingKey? invalidMessageRoutingKey = null)
+ : base(subscriptionName ?? new SubscriptionName(typeof(TRequest).FullName!),
+ channelName ?? new ChannelName(NatsNameSanitizer.Sanitize(typeof(TRequest).FullName!)),
+ routingKey ?? new RoutingKey(typeof(TRequest).FullName!),
+ consumer ?? NatsNameSanitizer.Sanitize(typeof(TRequest).FullName!),
+ typeof(TRequest),
+ getRequestType,
+ bufferSize,
+ noOfPerformers,
+ timeOut,
+ requeueCount,
+ requeueDelay,
+ unacceptableMessageLimit,
+ messagePumpType,
+ channelFactory,
+ makeChannels,
+ emptyChannelDelay,
+ channelFailureDelay,
+ unacceptableMessageLimitWindow,
+ ordered,
+ orderedConsumerOption,
+ idleHeartbeat,
+ priorityGroup,
+ streamConfiguration,
+ consumerOption,
+ deadLetterRoutingKey,
+ invalidMessageRoutingKey)
+ {
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/NatsSubscription.cs b/src/Paramore.Brighter.MessagingGateway.NATS/NatsSubscription.cs
new file mode 100644
index 0000000000..97ff9e624f
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/NatsSubscription.cs
@@ -0,0 +1,189 @@
+#region Licence
+
+/* The MIT License (MIT)
+Copyright © 2026 Ian Cooper
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the “Software”), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE. */
+
+#endregion
+
+using System;
+using NATS.Client.Core;
+
+namespace Paramore.Brighter.MessagingGateway.NATS;
+
+///
+/// Subscription configuration for consuming from a core NATS subject.
+///
+///
+/// Core NATS is at-most-once; for redelivery and acknowledgement semantics use
+/// instead. The channel name is used as the NATS subject to subscribe to.
+///
+public class NatsSubscription : Subscription, IUseBrighterDeadLetterSupport, IUseBrighterInvalidMessageSupport
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The of the subscription.
+ /// The ; used as the NATS subject to subscribe to.
+ /// The ; the topic messages are published under.
+ /// The of the request carried by messages, if fixed.
+ /// A that resolves the request from a , if it varies.
+ /// The number of messages to buffer for the channel; defaults to 1.
+ /// The number of threads pumping this channel; defaults to 1.
+ /// The to wait for a message before treating the channel as empty.
+ /// How many times a message is requeued before it is rejected; -1 for unlimited.
+ /// The to wait before requeuing a failed message.
+ /// How many unacceptable messages to tolerate before stopping the channel; 0 for unlimited.
+ /// The ; whether the pump reads messages synchronously or asynchronously.
+ /// An optional used to create the channel infrastructure.
+ /// The policy for missing infrastructure.
+ /// The to wait when the channel is empty.
+ /// The to wait when the channel fails.
+ /// The window the unacceptable message limit applies to.
+ /// An optional NATS queue group name; subscribers in the same queue group load-balance messages. Optional; defaults to , giving an ungrouped subscription that receives every message.
+ /// Optional to fine-tune the underlying NATS subscription.
+ public NatsSubscription(SubscriptionName subscriptionName,
+ ChannelName channelName,
+ RoutingKey routingKey,
+ Type? requestType = null,
+ Func? getRequestType = null,
+ int bufferSize = 1,
+ int noOfPerformers = 1,
+ TimeSpan? timeOut = null,
+ int requeueCount = -1,
+ TimeSpan? requeueDelay = null,
+ int unacceptableMessageLimit = 0,
+ MessagePumpType messagePumpType = MessagePumpType.Unknown,
+ IAmAChannelFactory? channelFactory = null,
+ OnMissingChannel makeChannels = OnMissingChannel.Create,
+ TimeSpan? emptyChannelDelay = null,
+ TimeSpan? channelFailureDelay = null,
+ TimeSpan? unacceptableMessageLimitWindow = null,
+ string? queueGroup = null,
+ NatsSubOpts? natsSubOpts = null)
+ : base(subscriptionName, channelName, routingKey, requestType,
+ getRequestType, bufferSize, noOfPerformers, timeOut,
+ requeueCount, requeueDelay, unacceptableMessageLimit,
+ messagePumpType, channelFactory, makeChannels,
+ emptyChannelDelay, channelFailureDelay,
+ unacceptableMessageLimitWindow)
+ {
+ QueueGroup = queueGroup;
+ NatsSubOpts = natsSubOpts;
+ }
+
+ ///
+ /// Gets the NATS queue group name for the subscription.
+ ///
+ /// The queue group name as a , or for an ungrouped subscription.
+ public string? QueueGroup { get; }
+
+ ///
+ /// Gets the low-level options for the underlying NATS subscription.
+ ///
+ /// The , or to use the client defaults.
+ public NatsSubOpts? NatsSubOpts { get; }
+
+ ///
+ /// Gets or sets the routing key of the dead-letter channel for rejected messages.
+ ///
+ /// The dead-letter , or when dead-lettering is not configured.
+ public RoutingKey? DeadLetterRoutingKey { get; set; }
+
+ ///
+ /// Gets or sets the routing key of the channel for messages that cannot be deserialized.
+ ///
+ /// The invalid-message , or when not configured.
+ public RoutingKey? InvalidMessageRoutingKey { get; set; }
+}
+
+///
+/// Subscription configuration for consuming from a core NATS subject, typed to a specific request.
+///
+///
+/// Defaults the subscription name, channel name (the NATS subject), and routing key to the full name of
+/// , which is a valid subject for core NATS.
+///
+/// The type of request that this subscription handles; must implement .
+public class NatsSubscription : NatsSubscription
+ where TRequest : class, IRequest
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The of the subscription. Optional; defaults to the full name of .
+ /// The ; used as the NATS subject to subscribe to. Optional; defaults to the full name of .
+ /// The ; the topic messages are published under. Optional; defaults to the full name of .
+ /// A that resolves the request from a ; optional, defaults to always returning .
+ /// The number of messages to buffer for the channel; defaults to 1.
+ /// The number of threads pumping this channel; defaults to 1.
+ /// The to wait for a message before treating the channel as empty.
+ /// How many times a message is requeued before it is rejected; -1 for unlimited.
+ /// The to wait before requeuing a failed message.
+ /// How many unacceptable messages to tolerate before stopping the channel; 0 for unlimited.
+ /// The ; whether the pump reads messages synchronously or asynchronously.
+ /// An optional used to create the channel infrastructure.
+ /// The policy for missing infrastructure.
+ /// The to wait when the channel is empty.
+ /// The to wait when the channel fails.
+ /// The window the unacceptable message limit applies to.
+ /// An optional NATS queue group name; subscribers in the same queue group load-balance messages. Optional; defaults to , giving an ungrouped subscription that receives every message.
+ /// Optional to fine-tune the underlying NATS subscription.
+ public NatsSubscription(
+ SubscriptionName? subscriptionName = null,
+ ChannelName? channelName = null,
+ RoutingKey? routingKey = null,
+ Func? getRequestType = null,
+ int bufferSize = 1,
+ int noOfPerformers = 1,
+ TimeSpan? timeOut = null,
+ int requeueCount = -1,
+ TimeSpan? requeueDelay = null,
+ int unacceptableMessageLimit = 0,
+ MessagePumpType messagePumpType = MessagePumpType.Unknown,
+ IAmAChannelFactory? channelFactory = null,
+ OnMissingChannel makeChannels = OnMissingChannel.Create,
+ TimeSpan? emptyChannelDelay = null,
+ TimeSpan? channelFailureDelay = null,
+ TimeSpan? unacceptableMessageLimitWindow = null,
+ string? queueGroup = null,
+ NatsSubOpts? natsSubOpts = null)
+ : base(subscriptionName ?? new SubscriptionName(typeof(TRequest).FullName!),
+ channelName ?? new ChannelName(typeof(TRequest).FullName!),
+ routingKey ?? new RoutingKey(typeof(TRequest).FullName!),
+ typeof(TRequest),
+ getRequestType,
+ bufferSize,
+ noOfPerformers,
+ timeOut,
+ requeueCount,
+ requeueDelay,
+ unacceptableMessageLimit,
+ messagePumpType,
+ channelFactory,
+ makeChannels,
+ emptyChannelDelay,
+ channelFailureDelay,
+ unacceptableMessageLimitWindow,
+ queueGroup,
+ natsSubOpts)
+ {
+ }
+}
diff --git a/src/Paramore.Brighter.MessagingGateway.NATS/Paramore.Brighter.MessagingGateway.NATS.csproj b/src/Paramore.Brighter.MessagingGateway.NATS/Paramore.Brighter.MessagingGateway.NATS.csproj
new file mode 100644
index 0000000000..3b4fd2d5df
--- /dev/null
+++ b/src/Paramore.Brighter.MessagingGateway.NATS/Paramore.Brighter.MessagingGateway.NATS.csproj
@@ -0,0 +1,15 @@
+
+
+ NATS and NATS JetStream messaging gateway implementation for Paramore.Brighter Command Processor. Provides decoupled message transport using NATS Core and JetStream with support for subjects, streams, pull consumers and durable consumers.
+ Rafael Andrade
+ $(BrighterTargetFrameworks)
+ NATS;JetStream;Message Gateway;Command;Event;Service Activator;Decoupled;Invocation;Messaging;Remote;Command Dispatcher;Command Processor;Request;Service;Task Queue;Work Queue;Retry;Circuit Breaker;Availability;Brighter
+ enable
+
+
+
+
+
+
+
+
diff --git a/src/Paramore.Brighter/Observability/MessagingSystem.cs b/src/Paramore.Brighter/Observability/MessagingSystem.cs
index b868194f9f..90f6b24ae8 100644
--- a/src/Paramore.Brighter/Observability/MessagingSystem.cs
+++ b/src/Paramore.Brighter/Observability/MessagingSystem.cs
@@ -1,4 +1,4 @@
-#region Licence
+#region Licence
/* The MIT License (MIT)
Copyright © 2024 Ian Cooper
@@ -35,6 +35,7 @@ public enum MessagingSystem
InternalBus,
JMS,
Kafka,
+ Nats,
PubSub,
RabbitMQ,
RocketMQ,
diff --git a/src/Paramore.Brighter/Observability/MessagingSystemExtensions.cs b/src/Paramore.Brighter/Observability/MessagingSystemExtensions.cs
index 6f8edb50f5..1d85ded92b 100644
--- a/src/Paramore.Brighter/Observability/MessagingSystemExtensions.cs
+++ b/src/Paramore.Brighter/Observability/MessagingSystemExtensions.cs
@@ -1,4 +1,4 @@
-#region Licence
+#region Licence
/* The MIT License (MIT)
Copyright © 2024 Ian Cooper
@@ -42,6 +42,7 @@ public static class MessagingSystemExtensions
MessagingSystem.InternalBus => "internal_bus",
MessagingSystem.JMS => "jms",
MessagingSystem.Kafka => "kafka",
+ MessagingSystem.Nats => "nats",
MessagingSystem.PubSub => "gcp_pubsub",
MessagingSystem.RabbitMQ => "rabbitmq",
MessagingSystem.RocketMQ => "rocketmq",
diff --git a/tests/Paramore.Brighter.NATS.Tests/Assembly.cs b/tests/Paramore.Brighter.NATS.Tests/Assembly.cs
new file mode 100644
index 0000000000..217120083b
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/Assembly.cs
@@ -0,0 +1,3 @@
+using Xunit;
+
+[assembly: CollectionBehavior(DisableTestParallelization = true)]
diff --git a/tests/Paramore.Brighter.NATS.Tests/DefaultMessageAssertion.cs b/tests/Paramore.Brighter.NATS.Tests/DefaultMessageAssertion.cs
new file mode 100644
index 0000000000..013aed1a70
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/DefaultMessageAssertion.cs
@@ -0,0 +1,66 @@
+#region Licence
+
+/* The MIT License (MIT)
+Copyright © 2014 Ian Cooper
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE. */
+
+#endregion
+
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+
+using Paramore.Brighter;
+
+namespace Paramore.Brighter.NATS.Tests;
+
+///
+/// Default implementation of that asserts equality of message headers and body.
+///
+public class DefaultMessageAssertion : IAmAMessageAssertion
+{
+ ///
+ /// Asserts that the actual message matches the expected message by comparing all header properties and body value.
+ ///
+ /// The expected message.
+ /// The actual message to compare.
+ public void Assert(Message expected, Message actual)
+ {
+ Xunit.Assert.Equal(expected.Header.MessageType, actual.Header.MessageType);
+ Xunit.Assert.Equal(expected.Header.ContentType, actual.Header.ContentType);
+ Xunit.Assert.Equal(expected.Header.CorrelationId, actual.Header.CorrelationId);
+ Xunit.Assert.Equal(expected.Header.DataSchema, actual.Header.DataSchema);
+ Xunit.Assert.Equal(expected.Header.MessageId, actual.Header.MessageId);
+ Xunit.Assert.Equal(expected.Header.PartitionKey, actual.Header.PartitionKey);
+ Xunit.Assert.Equal(expected.Header.ReplyTo, actual.Header.ReplyTo);
+ Xunit.Assert.Equal(expected.Header.Subject, actual.Header.Subject);
+ Xunit.Assert.Equal(expected.Header.SpecVersion, actual.Header.SpecVersion);
+ Xunit.Assert.Equal(expected.Header.Source, actual.Header.Source);
+ Xunit.Assert.Equal(expected.Header.Topic, actual.Header.Topic);
+ Xunit.Assert.Equal(expected.Header.TimeStamp.ToString("yyyy-MM-ddTHH:mm:ss"), actual.Header.TimeStamp.ToString("yyyy-MM-ddTHH:mm:ss"));
+ Xunit.Assert.Equal(expected.Header.Type, actual.Header.Type);
+ Xunit.Assert.Equal(expected.Header.HandledCount, actual.Header.HandledCount);
+ Xunit.Assert.Equal(System.TimeSpan.Zero, actual.Header.Delayed);
+ Xunit.Assert.Equal(expected.Body.Value, actual.Body.Value);
+ Xunit.Assert.Equal(expected.Header.TraceParent, actual.Header.TraceParent);
+ Xunit.Assert.Equal(expected.Header.TraceState, actual.Header.TraceState);
+ Xunit.Assert.Equal(expected.Header.Baggage, actual.Header.Baggage);
+ }
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/DefaultMessageBuilder.cs b/tests/Paramore.Brighter.NATS.Tests/DefaultMessageBuilder.cs
new file mode 100644
index 0000000000..6003387930
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/DefaultMessageBuilder.cs
@@ -0,0 +1,256 @@
+#region Licence
+
+/* The MIT License (MIT)
+Copyright © 2014 Ian Cooper
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE. */
+
+#endregion
+
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+using System;
+using System.Collections.Generic;
+using System.Net.Mime;
+using System.Text;
+
+using Paramore.Brighter.Observability;
+
+namespace Paramore.Brighter.NATS.Tests;
+
+///
+/// Default implementation of that creates messages with randomly generated test data.
+///
+public class DefaultMessageBuilder : IAmAMessageBuilder
+{
+ private Dictionary _bag = new()
+ {
+ ["header1"] = Uuid.NewAsString(),
+ ["header2"] = Uuid.NewAsString(),
+ ["header3"] = Uuid.NewAsString(),
+ ["header4"] = Uuid.NewAsString(),
+ ["header5"] = Uuid.NewAsString()
+ };
+ private Baggage _baggage = new();
+ private ContentType _contentType = new ContentType(MediaTypeNames.Text.Plain);
+ private Id _correlationId = Id.Random();
+ private Uri? _dataSchema = new Uri($"https://{Uuid.New():N}.test");
+ private string? _dataRef = Uuid.NewAsString();
+ private TimeSpan? _delayed;
+ private Id? _jobId = Id.Random();
+ private Id? _messageId = Id.Random();
+ private MessageType _messageType = MessageType.MT_EVENT;
+ private PartitionKey _partitionKey = PartitionKey.Empty;
+ private RoutingKey? _replyTo = new RoutingKey(Uuid.NewAsString());
+ private string? _subject = Uuid.NewAsString();
+ private string? _specVersion = "1.0";
+ private Uri? _source = new Uri(Uuid.NewAsString(), UriKind.Relative);
+ private RoutingKey _topic = new RoutingKey(Uuid.NewAsString());
+ private DateTimeOffset _timeStamp = DateTimeOffset.UtcNow;
+ private TraceParent? _traceParent = new TraceParent(Uuid.NewAsString());
+ private TraceState? _traceState = new TraceState(Uuid.NewAsString());
+ private CloudEventsType _type = new CloudEventsType(Uuid.NewAsString());
+ private Id? _workflowId = Id.Random();
+ private byte[] _body = Encoding.UTF8.GetBytes(Uuid.NewAsString());
+
+ ///
+ public IAmAMessageBuilder SetBag(Dictionary bag)
+ {
+ _bag = bag;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetBaggage(Baggage baggage)
+ {
+ _baggage = baggage;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetContentType(ContentType contentType)
+ {
+ _contentType = contentType;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetCorrelationId(Id correlationId)
+ {
+ _correlationId = correlationId;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetDataSchema(Uri? dataSchema)
+ {
+ _dataSchema = dataSchema;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetDataRef(string? dataRef)
+ {
+ _dataRef = dataRef;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetDelayed(TimeSpan? delayed)
+ {
+ _delayed = delayed;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetJobId(Id? jobId)
+ {
+ _jobId = jobId;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetMessageId(Id? messageId)
+ {
+ _messageId = messageId;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetMessageType(MessageType messageType)
+ {
+ _messageType = messageType;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetPartitionKey(PartitionKey partitionKey)
+ {
+ _partitionKey = partitionKey;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetReplyTo(RoutingKey? replyTo)
+ {
+ _replyTo = replyTo;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetSubject(string? subject)
+ {
+ _subject = subject;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetSpecVersion(string? specVersion)
+ {
+ _specVersion = specVersion;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetSource(Uri? source)
+ {
+ _source = source;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetTopic(RoutingKey topic)
+ {
+ _topic = topic;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetTimeStamp(DateTimeOffset timeStamp)
+ {
+ _timeStamp = timeStamp;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetTraceParent(TraceParent? traceParent)
+ {
+ _traceParent = traceParent;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetTraceState(TraceState? traceState)
+ {
+ _traceState = traceState;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetType(CloudEventsType type)
+ {
+ _type = type;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetWorkflowId(Id? workflowId)
+ {
+ _workflowId = workflowId;
+ return this;
+ }
+
+ ///
+ public IAmAMessageBuilder SetBody(byte[] body)
+ {
+ _body = body;
+ return this;
+ }
+
+ ///
+ public Message Build()
+ {
+ var messageHeader = new MessageHeader(
+ messageId: _messageId,
+ topic: _topic,
+ messageType: _messageType,
+ source: _source,
+ type: _type,
+ timeStamp: _timeStamp,
+ correlationId: _correlationId,
+ replyTo: _replyTo,
+ contentType: _contentType,
+ partitionKey: _partitionKey,
+ subject: _subject,
+ dataSchema: _dataSchema,
+ delayed: _delayed,
+ traceParent: _traceParent,
+ traceState: _traceState,
+ baggage: _baggage,
+ workflowId: _workflowId,
+ jobId: _jobId)
+ {
+ SpecVersion = _specVersion,
+ Bag = _bag
+ };
+
+ return new Message(messageHeader, new MessageBody(_body));
+ }
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/IAmAMessageAssertion.cs b/tests/Paramore.Brighter.NATS.Tests/IAmAMessageAssertion.cs
new file mode 100644
index 0000000000..304c886776
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/IAmAMessageAssertion.cs
@@ -0,0 +1,45 @@
+#region Licence
+
+/* The MIT License (MIT)
+Copyright © 2014 Ian Cooper
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE. */
+
+#endregion
+
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+
+using Paramore.Brighter;
+
+namespace Paramore.Brighter.NATS.Tests;
+
+///
+/// Defines a contract for asserting equality between two Message instances.
+///
+public interface IAmAMessageAssertion
+{
+ ///
+ /// Asserts that the actual message matches the expected message.
+ ///
+ /// The expected message.
+ /// The actual message to compare.
+ void Assert(Message expected, Message actual);
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/IAmAMessageBuilder.cs b/tests/Paramore.Brighter.NATS.Tests/IAmAMessageBuilder.cs
new file mode 100644
index 0000000000..aa76b479fd
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/IAmAMessageBuilder.cs
@@ -0,0 +1,204 @@
+#region Licence
+
+/* The MIT License (MIT)
+Copyright © 2014 Ian Cooper
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE. */
+
+#endregion
+
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+using System;
+using System.Collections.Generic;
+using System.Net.Mime;
+using System.Text;
+
+using Paramore.Brighter;
+using Paramore.Brighter.Observability;
+
+namespace Paramore.Brighter.NATS.Tests;
+
+///
+/// Builder interface for creating test messages with customizable header values and body content.
+///
+public interface IAmAMessageBuilder
+{
+ ///
+ /// Sets the bag of additional header values.
+ ///
+ /// The dictionary of additional header values.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetBag(Dictionary bag);
+
+ ///
+ /// Sets the baggage for distributed tracing context propagation.
+ ///
+ /// The baggage for distributed tracing.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetBaggage(Baggage baggage);
+
+ ///
+ /// Sets the content type of the message body.
+ ///
+ /// The content type of the message.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetContentType(ContentType contentType);
+
+ ///
+ /// Sets the correlation identifier for tracking related messages.
+ ///
+ /// The correlation identifier.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetCorrelationId(Id correlationId);
+
+ ///
+ /// Sets the URI identifying the schema of the data in the message body.
+ ///
+ /// The data schema URI.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetDataSchema(Uri? dataSchema);
+
+ ///
+ /// Sets a reference to external data.
+ ///
+ /// The external data reference.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetDataRef(string? dataRef);
+
+ ///
+ /// Sets the delay before the message should be processed.
+ ///
+ /// The delay timespan.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetDelayed(TimeSpan? delayed);
+
+ ///
+ /// Sets the job identifier for workflow tracking.
+ ///
+ /// The job identifier.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetJobId(Id? jobId);
+
+ ///
+ /// Sets the unique message identifier.
+ ///
+ /// The message identifier.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetMessageId(Id? messageId);
+
+ ///
+ /// Sets the message type (Event, Command, etc.).
+ ///
+ /// The message type.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetMessageType(MessageType messageType);
+
+ ///
+ /// Sets the partition key for message routing.
+ ///
+ /// The partition key.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetPartitionKey(PartitionKey partitionKey);
+
+ ///
+ /// Sets the reply-to routing key for response messages.
+ ///
+ /// The reply-to routing key.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetReplyTo(RoutingKey? replyTo);
+
+ ///
+ /// Sets the subject or title of the message.
+ ///
+ /// The message subject.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetSubject(string? subject);
+
+ ///
+ /// Sets the CloudEvents specification version.
+ ///
+ /// The CloudEvents spec version.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetSpecVersion(string? specVersion);
+
+ ///
+ /// Sets the URI identifying the source of the message.
+ ///
+ /// The message source URI.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetSource(Uri? source);
+
+ ///
+ /// Sets the topic routing key for message publishing.
+ ///
+ /// The topic routing key.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetTopic(RoutingKey topic);
+
+ ///
+ /// Sets the timestamp when the message was created.
+ ///
+ /// The message timestamp.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetTimeStamp(DateTimeOffset timeStamp);
+
+ ///
+ /// Sets the W3C trace parent for distributed tracing.
+ ///
+ /// The W3C trace parent.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetTraceParent(TraceParent? traceParent);
+
+ ///
+ /// Sets the W3C trace state for vendor-specific tracing information.
+ ///
+ /// The W3C trace state.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetTraceState(TraceState? traceState);
+
+ ///
+ /// Sets the CloudEvents type describing the message content.
+ ///
+ /// The CloudEvents type.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetType(CloudEventsType type);
+
+ ///
+ /// Sets the workflow identifier for process tracking.
+ ///
+ /// The workflow identifier.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetWorkflowId(Id? workflowId);
+
+ ///
+ /// Sets the message body content as a byte array.
+ ///
+ /// The message body content.
+ /// The builder instance for method chaining.
+ IAmAMessageBuilder SetBody(byte[] body);
+
+ ///
+ /// Builds and returns the configured message.
+ ///
+ /// The constructed message with all configured properties.
+ Message Build();
+}
+
diff --git a/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/IAmAMessageGatewayProactorProvider.cs b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/IAmAMessageGatewayProactorProvider.cs
new file mode 100644
index 0000000000..fb940af9e6
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/IAmAMessageGatewayProactorProvider.cs
@@ -0,0 +1,81 @@
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Paramore.Brighter.NATS.Tests.MessagingGateway.Proactor;
+
+///
+/// Defines a provider for creating and managing asynchronous messaging gateway components for testing.
+///
+public interface IAmAMessageGatewayProactorProvider
+{
+ ///
+ /// Gets or creates a routing key based on the test name.
+ ///
+ /// The name of the test. Automatically populated with the caller member name.
+ /// A routing key for message routing.
+ RoutingKey GetOrCreateRoutingKey([CallerMemberName]string testName = null!);
+
+ ///
+ /// Gets or creates a channel name based on the test name.
+ ///
+ /// The name of the test. Automatically populated with the caller member name.
+ /// A channel name for message consumption.
+ ChannelName GetOrCreateChannelName([CallerMemberName]string testName = null!);
+
+ ///
+ /// Creates a publication configuration for the specified routing key.
+ ///
+ /// The routing key for message publishing.
+ /// The action to take when the channel is missing. Defaults to Create.
+ /// A publication configuration.
+ Paramore.Brighter.MessagingGateway.NATS.NatsPublication CreatePublication(RoutingKey routingKey, OnMissingChannel makeChannels = OnMissingChannel.Create);
+
+ ///
+ /// Creates a subscription configuration for the specified routing key and channel.
+ ///
+ /// The routing key to subscribe to.
+ /// The channel name for receiving messages.
+ /// The action to take when the channel is missing.
+ /// Whether to set up a dead letter queue.
+ /// A subscription configuration.
+ Paramore.Brighter.MessagingGateway.NATS.NatsSubscription CreateSubscription(RoutingKey routingKey, ChannelName channelName, OnMissingChannel makeChannel, bool setupDeadLetterQueue = false);
+
+ ///
+ /// Retrieves a message from the dead letter queue for the specified subscription.
+ ///
+ /// The subscription configuration.
+ /// A cancellation token to cancel the operation.
+ /// A message from the dead letter queue.
+ Task GetMessageFromDeadLetterQueueAsync(Paramore.Brighter.MessagingGateway.NATS.NatsSubscription subscription, CancellationToken cancellationToken = default);
+
+ ///
+ /// Creates an asynchronous message producer for the specified publication.
+ ///
+ /// The publication configuration.
+ /// A cancellation token to cancel the operation.
+ /// An asynchronous message producer.
+ Task CreateProducerAsync(Paramore.Brighter.MessagingGateway.NATS.NatsPublication publication, CancellationToken cancellationToken = default);
+
+ ///
+ /// Creates an asynchronous channel for the specified subscription.
+ ///
+ /// The subscription configuration.
+ /// A cancellation token to cancel the operation.
+ /// An asynchronous channel for receiving messages.
+ Task CreateChannelAsync(Paramore.Brighter.MessagingGateway.NATS.NatsSubscription subscription, CancellationToken cancellationToken = default);
+
+ ///
+ /// Cleans up the specified producer and channel resources.
+ ///
+ /// The message producer to clean up, or null.
+ /// The channel to clean up, or null.
+ /// The messages to clean up.
+ /// A task representing the cleanup operation.
+ Task CleanUpAsync(IAmAMessageProducerAsync? producer, IAmAChannelAsync? channel, IEnumerable messages);
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_a_message_consumer_reads_multiple_messages_should_receive_all_messages_async.cs b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_a_message_consumer_reads_multiple_messages_should_receive_all_messages_async.cs
new file mode 100644
index 0000000000..a78766cde2
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_a_message_consumer_reads_multiple_messages_should_receive_all_messages_async.cs
@@ -0,0 +1,91 @@
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+using Xunit;
+
+using Paramore.Brighter.Extensions;
+
+namespace Paramore.Brighter.NATS.Tests.MessagingGateway.Proactor;
+
+[Trait("Category", "NATS")]
+[Collection("NATSMessagingGateway")]
+public class WhenAMessageConsumerReadsMultipleMessagesShouldReceiveAllMessagesAsync : IAsyncLifetime
+{
+ private readonly IAmAMessageGatewayProactorProvider _messageGatewayProvider;
+ private readonly IAmAMessageBuilder _messageBuilder;
+ private readonly IAmAMessageAssertion _messageAssertion;
+
+ private List _sentMessages = [];
+
+ private Paramore.Brighter.MessagingGateway.NATS.NatsSubscription? _subscription;
+ private Paramore.Brighter.MessagingGateway.NATS.NatsPublication? _publication;
+
+ private IAmAMessageProducerAsync? _producer;
+ private IAmAChannelAsync? _channel;
+
+ public WhenAMessageConsumerReadsMultipleMessagesShouldReceiveAllMessagesAsync()
+ {
+ _messageGatewayProvider = new Paramore.Brighter.NATS.Tests.MessagingGateway.NatsMessageGatewayProvider();
+ _messageBuilder = new DefaultMessageBuilder();
+ _messageAssertion = new DefaultMessageAssertion();
+ }
+
+ public Task InitializeAsync()
+ {
+ return Task.CompletedTask;
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _messageGatewayProvider.CleanUpAsync(_producer, _channel, _sentMessages);
+ }
+
+ [Fact]
+ public async Task When_a_message_consumer_reads_multiple_messages_should_receive_all_messages_async()
+ {
+ // Arrange
+ _publication = _messageGatewayProvider.CreatePublication(_messageGatewayProvider.GetOrCreateRoutingKey());
+ _subscription = _messageGatewayProvider.CreateSubscription(_publication.Topic!,
+ _messageGatewayProvider.GetOrCreateChannelName(),
+ OnMissingChannel.Create);
+
+ _producer = await _messageGatewayProvider.CreateProducerAsync(_publication);
+ _channel = await _messageGatewayProvider.CreateChannelAsync(_subscription);
+
+ _sentMessages =
+ [
+ _messageBuilder.SetTopic(_publication.Topic!).SetMessageId(Id.Random()).SetBody(Encoding.UTF8.GetBytes(Id.Random().ToString())).Build(),
+ _messageBuilder.SetTopic(_publication.Topic!).SetMessageId(Id.Random()).SetBody(Encoding.UTF8.GetBytes(Id.Random().ToString())).Build(),
+ _messageBuilder.SetTopic(_publication.Topic!).SetMessageId(Id.Random()).SetBody(Encoding.UTF8.GetBytes(Id.Random().ToString())).Build(),
+ _messageBuilder.SetTopic(_publication.Topic!).SetMessageId(Id.Random()).SetBody(Encoding.UTF8.GetBytes(Id.Random().ToString())).Build()
+ ];
+
+ // Act
+ await _sentMessages.EachAsync(async message => await _producer.SendAsync(message));
+
+
+
+ // Assert
+ for (var i = 0; i < _sentMessages.Count; i++)
+ {
+ var received = await _channel.ReceiveAsync(TimeSpan.FromMilliseconds(4000));
+
+ Assert.NotEqual(MessageType.MT_NONE, received.Header.MessageType);
+
+ var expectedMessage = _sentMessages.FirstOrDefault(x => x.Header.MessageId == received.Header.MessageId);
+ Assert.NotNull(expectedMessage);
+
+ _messageAssertion.Assert(expectedMessage, received);
+
+ await _channel.AcknowledgeAsync(received);
+
+
+ }
+ }
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_multiple_threads_try_to_post_a_message_at_the_same_time_should_not_throw_exception.cs b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_multiple_threads_try_to_post_a_message_at_the_same_time_should_not_throw_exception.cs
new file mode 100644
index 0000000000..1bbd74d125
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_multiple_threads_try_to_post_a_message_at_the_same_time_should_not_throw_exception.cs
@@ -0,0 +1,55 @@
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Xunit;
+
+namespace Paramore.Brighter.NATS.Tests.MessagingGateway.Proactor;
+
+[Trait("Category", "NATS")]
+[Collection("NATSMessagingGateway")]
+public class WhenMultipleThreadsTryToPostAMessageAtTheSameTimeShouldNotThrowExceptionAsync : IAsyncLifetime
+{
+ private readonly IAmAMessageGatewayProactorProvider _messageGatewayProvider;
+
+ private Paramore.Brighter.MessagingGateway.NATS.NatsPublication? _publication;
+
+ private IAmAMessageProducerAsync? _producer;
+
+ public WhenMultipleThreadsTryToPostAMessageAtTheSameTimeShouldNotThrowExceptionAsync()
+ {
+ _messageGatewayProvider = new Paramore.Brighter.NATS.Tests.MessagingGateway.NatsMessageGatewayProvider();
+ }
+
+ public Task InitializeAsync()
+ {
+ return Task.CompletedTask;
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _messageGatewayProvider.CleanUpAsync(_producer, null, []);
+ }
+
+ [Fact]
+ public async Task When_multiple_threads_try_to_post_a_message_at_the_same_time_should_not_throw_exception_async()
+ {
+ // Arrange
+ _publication = _messageGatewayProvider.CreatePublication(_messageGatewayProvider.GetOrCreateRoutingKey());
+ _producer = await _messageGatewayProvider.CreateProducerAsync(_publication);
+
+ // Act
+ var options = new ParallelOptions { MaxDegreeOfParallelism = 4 };
+ await Parallel.ForEachAsync(Enumerable.Range(0, 10), options, async (_, ct) =>
+ {
+ var message = new DefaultMessageBuilder().SetTopic(_publication.Topic!).Build();
+ await _producer.SendAsync(message, ct);
+ });
+
+ // Assert
+ Assert.True(true);
+ }
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_posting_a_message_via_the_messaging_gateway_should_be_received.cs b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_posting_a_message_via_the_messaging_gateway_should_be_received.cs
new file mode 100644
index 0000000000..8e7908594d
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_posting_a_message_via_the_messaging_gateway_should_be_received.cs
@@ -0,0 +1,69 @@
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+using Xunit;
+
+namespace Paramore.Brighter.NATS.Tests.MessagingGateway.Proactor;
+
+[Trait("Category", "NATS")]
+[Collection("NATSMessagingGateway")]
+public class WhenPostingAMessageViaTheMessagingGatewayShouldBeReceivedAsync : IAsyncLifetime
+{
+ private readonly IAmAMessageGatewayProactorProvider _messageGatewayProvider;
+ private readonly IAmAMessageBuilder _messageBuilder;
+ private readonly IAmAMessageAssertion _messageAssertion;
+
+ private List _sentMessages = [];
+
+ private Paramore.Brighter.MessagingGateway.NATS.NatsSubscription? _subscription;
+ private Paramore.Brighter.MessagingGateway.NATS.NatsPublication? _publication;
+
+ private IAmAMessageProducerAsync? _producer;
+ private IAmAChannelAsync? _channel;
+
+ public WhenPostingAMessageViaTheMessagingGatewayShouldBeReceivedAsync()
+ {
+ _messageGatewayProvider = new Paramore.Brighter.NATS.Tests.MessagingGateway.NatsMessageGatewayProvider();
+ _messageBuilder = new DefaultMessageBuilder();
+ _messageAssertion = new DefaultMessageAssertion();
+ }
+
+ public Task InitializeAsync()
+ {
+ return Task.CompletedTask;
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _messageGatewayProvider.CleanUpAsync(_producer, _channel, _sentMessages);
+ }
+
+ [Fact]
+ public async Task When_posting_a_message_via_the_messaging_gateway_should_be_received_async()
+ {
+ // Arrange
+ _publication = _messageGatewayProvider.CreatePublication(_messageGatewayProvider.GetOrCreateRoutingKey());
+ _subscription = _messageGatewayProvider.CreateSubscription(_publication.Topic!,
+ _messageGatewayProvider.GetOrCreateChannelName(),
+ OnMissingChannel.Create);
+
+ _producer = await _messageGatewayProvider.CreateProducerAsync(_publication);
+ _channel = await _messageGatewayProvider.CreateChannelAsync(_subscription);
+
+ var message = _messageBuilder.SetTopic(_publication.Topic!).Build();
+ _sentMessages.Add(message);
+
+ // Act
+ await _producer.SendAsync(message);
+
+ var received = await _channel.ReceiveAsync(TimeSpan.FromMilliseconds(4000));
+
+ // Assert
+ Assert.NotEqual(MessageType.MT_NONE, received.Header.MessageType);
+ _messageAssertion.Assert(message, received);
+ }
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_requeuing_a_failed_message_should_receive_message_again.cs b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_requeuing_a_failed_message_should_receive_message_again.cs
new file mode 100644
index 0000000000..7350866b81
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_requeuing_a_failed_message_should_receive_message_again.cs
@@ -0,0 +1,83 @@
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+using Xunit;
+
+namespace Paramore.Brighter.NATS.Tests.MessagingGateway.Proactor;
+
+[Trait("Category", "NATS")]
+[Collection("NATSMessagingGateway")]
+public class WhenRequeuingAFailedMessageShouldReceiveMessageAgainAsync : IAsyncLifetime
+{
+ private readonly IAmAMessageGatewayProactorProvider _messageGatewayProvider;
+ private readonly IAmAMessageBuilder _messageBuilder;
+ private readonly IAmAMessageAssertion _messageAssertion;
+
+ private List _sentMessages = [];
+
+ private Paramore.Brighter.MessagingGateway.NATS.NatsSubscription? _subscription;
+ private Paramore.Brighter.MessagingGateway.NATS.NatsPublication? _publication;
+
+ private IAmAMessageProducerAsync? _producer;
+ private IAmAChannelAsync? _channel;
+
+ public WhenRequeuingAFailedMessageShouldReceiveMessageAgainAsync()
+ {
+ _messageGatewayProvider = new Paramore.Brighter.NATS.Tests.MessagingGateway.NatsMessageGatewayProvider();
+ _messageBuilder = new DefaultMessageBuilder();
+ _messageAssertion = new DefaultMessageAssertion();
+ }
+
+ public Task InitializeAsync()
+ {
+ return Task.CompletedTask;
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _messageGatewayProvider.CleanUpAsync(_producer, _channel, _sentMessages);
+ }
+
+ [Fact]
+ public async Task When_requeuing_a_failed_message_should_receive_message_again_async()
+ {
+ // Arrange
+ _publication = _messageGatewayProvider.CreatePublication(_messageGatewayProvider.GetOrCreateRoutingKey());
+ _subscription = _messageGatewayProvider.CreateSubscription(_publication.Topic!,
+ _messageGatewayProvider.GetOrCreateChannelName(),
+ OnMissingChannel.Create);
+
+ _producer = await _messageGatewayProvider.CreateProducerAsync(_publication);
+ _channel = await _messageGatewayProvider.CreateChannelAsync(_subscription);
+
+ var message = _messageBuilder.SetTopic(_publication.Topic!).Build();
+ _sentMessages.Add(message);
+
+ await _producer.SendAsync(message);
+
+ // Act
+ var received = await _channel.ReceiveAsync(TimeSpan.FromMilliseconds(4000));
+ Assert.NotEqual(MessageType.MT_NONE, received.Header.MessageType);
+
+ await _channel.RequeueAsync(received);
+
+ // Retry receiving in case the requeued message is not immediately available
+ var requeued = new Message();
+ for (var i = 0; i < 10; i++)
+ {
+ requeued = await _channel.ReceiveAsync(TimeSpan.FromMilliseconds(4000));
+ if (requeued.Header.MessageType != MessageType.MT_NONE)
+ {
+ break;
+ }
+ }
+
+ // Assert
+ Assert.NotEqual(MessageType.MT_NONE, requeued.Header.MessageType);
+ _messageAssertion.Assert(message, requeued);
+ }
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_sending_a_message_should_propagate_activity_context.cs b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_sending_a_message_should_propagate_activity_context.cs
new file mode 100644
index 0000000000..9b6af4e732
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Proactor/When_sending_a_message_should_propagate_activity_context.cs
@@ -0,0 +1,93 @@
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading.Tasks;
+
+using OpenTelemetry;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+using Xunit;
+
+namespace Paramore.Brighter.NATS.Tests.MessagingGateway.Proactor;
+
+[Trait("Category", "NATS")]
+[Collection("NATSMessagingGateway")]
+public class WhenSendingAMessageShouldPropagateActivityContextAsync : IAsyncLifetime
+{
+ private readonly IAmAMessageGatewayProactorProvider _messageGatewayProvider;
+ private readonly IAmAMessageBuilder _messageBuilder;
+
+ private List _sentMessages = [];
+
+ private Paramore.Brighter.MessagingGateway.NATS.NatsSubscription? _subscription;
+ private Paramore.Brighter.MessagingGateway.NATS.NatsPublication? _publication;
+
+ private IAmAMessageProducerAsync? _producer;
+ private IAmAChannelAsync? _channel;
+
+ public WhenSendingAMessageShouldPropagateActivityContextAsync()
+ {
+ _messageGatewayProvider = new Paramore.Brighter.NATS.Tests.MessagingGateway.NatsMessageGatewayProvider();
+ _messageBuilder = new DefaultMessageBuilder();
+ }
+
+ public Task InitializeAsync()
+ {
+ return Task.CompletedTask;
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _messageGatewayProvider.CleanUpAsync(_producer, _channel, _sentMessages);
+ }
+
+ [Fact]
+ public async Task When_sending_a_message_should_propagate_activity_context_async()
+ {
+ // Arrange
+ var builder = Sdk.CreateTracerProviderBuilder();
+ var exportedActivities = new List();
+
+ using var tracerProvider = builder
+ .AddSource("Paramore.Brighter.Tests", "Paramore.Brighter")
+ .ConfigureResource(r => r.AddService("message-producer-tracer"))
+ .AddInMemoryExporter(exportedActivities)
+ .Build();
+
+ using var parentActivity = new ActivitySource("Paramore.Brighter.Tests").StartActivity("MessageProducerTests");
+ parentActivity!.TraceStateString = "brighter=00f067aa0ba902b7,congo=t61rcWkgMzE";
+
+ Baggage.SetBaggage("key", "value");
+ Baggage.SetBaggage("key2", "value2");
+
+ _publication = _messageGatewayProvider.CreatePublication(_messageGatewayProvider.GetOrCreateRoutingKey());
+ _subscription = _messageGatewayProvider.CreateSubscription(_publication.Topic!,
+ _messageGatewayProvider.GetOrCreateChannelName(),
+ OnMissingChannel.Create);
+
+ _producer = await _messageGatewayProvider.CreateProducerAsync(_publication);
+ _channel = await _messageGatewayProvider.CreateChannelAsync(_subscription);
+ _producer.Span = parentActivity;
+
+ var message = _messageBuilder
+ .SetTopic(_publication.Topic!)
+ .SetTraceParent(null)
+ .SetTraceState(null)
+ .SetBaggage(new Paramore.Brighter.Observability.Baggage())
+ .Build();
+ _sentMessages.Add(message);
+
+ // Act
+ await _producer.SendAsync(message);
+
+ tracerProvider.ForceFlush();
+
+ // Assert
+ Assert.NotNull(message.Header.TraceParent);
+ Assert.Equal("brighter=00f067aa0ba902b7,congo=t61rcWkgMzE", message.Header.TraceState);
+ Assert.Equal("key=value,key2=value2", message.Header.Baggage.ToString());
+ }
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/IAmAMessageGatewayReactorProvider.cs b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/IAmAMessageGatewayReactorProvider.cs
new file mode 100644
index 0000000000..cbac867d5f
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/IAmAMessageGatewayReactorProvider.cs
@@ -0,0 +1,76 @@
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+
+namespace Paramore.Brighter.NATS.Tests.MessagingGateway.Reactor;
+
+///
+/// Defines a provider for creating and managing synchronous messaging gateway components for testing.
+///
+public interface IAmAMessageGatewayReactorProvider
+{
+ ///
+ /// Gets or creates a routing key based on the test name.
+ ///
+ /// The name of the test. Automatically populated with the caller member name.
+ /// A routing key for message routing.
+ RoutingKey GetOrCreateRoutingKey([CallerMemberName]string testName = null!);
+
+ ///
+ /// Gets or creates a channel name based on the test name.
+ ///
+ /// The name of the test. Automatically populated with the caller member name.
+ /// A channel name for message consumption.
+ ChannelName GetOrCreateChannelName([CallerMemberName]string testName = null!);
+
+ ///
+ /// Creates a publication configuration for the specified routing key.
+ ///
+ /// The routing key for message publishing.
+ /// The action to take when the channel is missing. Defaults to Create.
+ /// A publication configuration.
+ Paramore.Brighter.MessagingGateway.NATS.NatsPublication CreatePublication(RoutingKey routingKey, OnMissingChannel makeChannels = OnMissingChannel.Create);
+
+ ///
+ /// Creates a subscription configuration for the specified routing key and channel.
+ ///
+ /// The routing key to subscribe to.
+ /// The channel name for receiving messages.
+ /// The action to take when the channel is missing.
+ /// Whether to set up a dead letter queue.
+ /// A subscription configuration.
+ Paramore.Brighter.MessagingGateway.NATS.NatsSubscription CreateSubscription(RoutingKey routingKey, ChannelName channelName, OnMissingChannel makeChannel, bool setupDeadLetterQueue = false);
+
+ ///
+ /// Creates an synchronous message producer for the specified publication.
+ ///
+ /// The publication configuration.
+ /// An synchronous message producer.
+ IAmAMessageProducerSync CreateProducer(Paramore.Brighter.MessagingGateway.NATS.NatsPublication publication);
+
+ ///
+ /// Creates an synchronous channel for the specified subscription.
+ ///
+ /// The subscription configuration.
+ /// An asynchronous channel for receiving messages.
+ IAmAChannelSync CreateChannel(Paramore.Brighter.MessagingGateway.NATS.NatsSubscription subscription);
+
+ ///
+ /// Cleans up the specified producer and channel resources.
+ ///
+ /// The message producer to clean up, or null.
+ /// The channel to clean up, or null.
+ /// The messages to clean up.
+ /// A task representing the cleanup operation.
+ void CleanUp(IAmAMessageProducerSync? producer, IAmAChannelSync? channel, IEnumerable messages);
+
+ ///
+ /// Gets a message from the dead letter queue for the specified subscription.
+ ///
+ /// The subscription configuration.
+ /// The message from the dead letter queue.
+ Message GetMessageFromDeadLetterQueue(Paramore.Brighter.MessagingGateway.NATS.NatsSubscription subscription);
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_a_message_consumer_reads_multiple_messages_should_receive_all_messages.cs b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_a_message_consumer_reads_multiple_messages_should_receive_all_messages.cs
new file mode 100644
index 0000000000..dacfd0311f
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_a_message_consumer_reads_multiple_messages_should_receive_all_messages.cs
@@ -0,0 +1,82 @@
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading;
+
+using Paramore.Brighter.Extensions;
+
+using Xunit;
+
+namespace Paramore.Brighter.NATS.Tests.MessagingGateway.Reactor;
+
+[Trait("Category", "NATS")]
+[Collection("NATSMessagingGateway")]
+public class WhenAMessageConsumerReadsMultipleMessagesShouldReceiveAllMessages : IDisposable
+{
+ private readonly IAmAMessageGatewayReactorProvider _messageGatewayProvider;
+ private readonly IAmAMessageBuilder _messageBuilder;
+ private readonly IAmAMessageAssertion _messageAssertion;
+
+ private List _sentMessages = [];
+
+ private Paramore.Brighter.MessagingGateway.NATS.NatsSubscription? _subscription;
+ private Paramore.Brighter.MessagingGateway.NATS.NatsPublication? _publication;
+
+ private IAmAMessageProducerSync? _producer;
+ private IAmAChannelSync? _channel;
+
+ public WhenAMessageConsumerReadsMultipleMessagesShouldReceiveAllMessages()
+ {
+ _messageGatewayProvider = new Paramore.Brighter.NATS.Tests.MessagingGateway.NatsMessageGatewayProvider();
+ _messageBuilder = new DefaultMessageBuilder();
+ _messageAssertion = new DefaultMessageAssertion();
+ }
+
+ public void Dispose()
+ {
+ _messageGatewayProvider.CleanUp(_producer, _channel, _sentMessages);
+ }
+
+ [Fact]
+ public void When_a_message_consumer_reads_multiple_messages_should_receive_all_messages()
+ {
+ // Arrange
+ _publication = _messageGatewayProvider.CreatePublication(_messageGatewayProvider.GetOrCreateRoutingKey());
+ _subscription = _messageGatewayProvider.CreateSubscription(_publication.Topic!,
+ _messageGatewayProvider.GetOrCreateChannelName(),
+ OnMissingChannel.Create);
+
+ _producer = _messageGatewayProvider.CreateProducer(_publication);
+ _channel = _messageGatewayProvider.CreateChannel(_subscription);
+
+ _sentMessages =
+ [
+ _messageBuilder.SetTopic(_publication.Topic!).SetMessageId(Id.Random()).SetBody(Encoding.UTF8.GetBytes(Id.Random().ToString())).Build(),
+ _messageBuilder.SetTopic(_publication.Topic!).SetMessageId(Id.Random()).SetBody(Encoding.UTF8.GetBytes(Id.Random().ToString())).Build(),
+ _messageBuilder.SetTopic(_publication.Topic!).SetMessageId(Id.Random()).SetBody(Encoding.UTF8.GetBytes(Id.Random().ToString())).Build(),
+ _messageBuilder.SetTopic(_publication.Topic!).SetMessageId(Id.Random()).SetBody(Encoding.UTF8.GetBytes(Id.Random().ToString())).Build()
+ ];
+
+ // Act
+ _sentMessages.Each(message => _producer.Send(message));
+
+ // Assert
+ for (var i = 0; i < _sentMessages.Count; i++)
+ {
+ var received = _channel.Receive(TimeSpan.FromMilliseconds(4000));
+
+ Assert.NotEqual(MessageType.MT_NONE, received.Header.MessageType);
+
+ var expectedMessage = _sentMessages.FirstOrDefault(x => x.Header.MessageId == received.Header.MessageId);
+ Assert.NotNull(expectedMessage);
+
+ _messageAssertion.Assert(expectedMessage, received);
+
+ _channel.Acknowledge(received);
+ }
+ }
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_multiple_threads_try_to_post_a_message_at_the_same_time_should_not_throw_exception.cs b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_multiple_threads_try_to_post_a_message_at_the_same_time_should_not_throw_exception.cs
new file mode 100644
index 0000000000..ef89c3f151
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_multiple_threads_try_to_post_a_message_at_the_same_time_should_not_throw_exception.cs
@@ -0,0 +1,52 @@
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Xunit;
+
+namespace Paramore.Brighter.NATS.Tests.MessagingGateway.Reactor;
+
+[Trait("Category", "NATS")]
+[Collection("NATSMessagingGateway")]
+public class WhenMultipleThreadsTryToPostAMessageAtTheSameTimeShouldNotThrowException : IDisposable
+{
+ private readonly IAmAMessageGatewayReactorProvider _messageGatewayProvider;
+
+ private Paramore.Brighter.MessagingGateway.NATS.NatsPublication? _publication;
+
+ private IAmAMessageProducerSync? _producer;
+
+ public WhenMultipleThreadsTryToPostAMessageAtTheSameTimeShouldNotThrowException()
+ {
+ _messageGatewayProvider = new Paramore.Brighter.NATS.Tests.MessagingGateway.NatsMessageGatewayProvider();
+ }
+
+ public void Dispose()
+ {
+ _messageGatewayProvider.CleanUp(_producer, null, []);
+ }
+
+ [Fact]
+ public void When_multiple_threads_try_to_post_a_message_at_the_same_time_should_not_throw_exception()
+ {
+ // Arrange
+ _publication = _messageGatewayProvider.CreatePublication(_messageGatewayProvider.GetOrCreateRoutingKey());
+ _producer = _messageGatewayProvider.CreateProducer(_publication);
+
+ // Act
+ var options = new ParallelOptions { MaxDegreeOfParallelism = 4 };
+ Parallel.ForEach(Enumerable.Range(0, 10), options, _ =>
+ {
+ var message = new DefaultMessageBuilder().SetTopic(_publication.Topic!).Build();
+ _producer.Send(message);
+ });
+
+ // Assert
+ Assert.True(true);
+ }
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_posting_a_message_via_the_messaging_gateway_should_be_received.cs b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_posting_a_message_via_the_messaging_gateway_should_be_received.cs
new file mode 100644
index 0000000000..268a03802f
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_posting_a_message_via_the_messaging_gateway_should_be_received.cs
@@ -0,0 +1,64 @@
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+using System;
+using System.Collections.Generic;
+using System.Threading;
+
+using Xunit;
+
+namespace Paramore.Brighter.NATS.Tests.MessagingGateway.Reactor;
+
+[Trait("Category", "NATS")]
+[Collection("NATSMessagingGateway")]
+public class WhenPostingAMessageViaTheMessagingGatewayShouldBeReceived : IDisposable
+{
+ private readonly IAmAMessageGatewayReactorProvider _messageGatewayProvider;
+ private readonly IAmAMessageBuilder _messageBuilder;
+ private readonly IAmAMessageAssertion _messageAssertion;
+
+ private List _sentMessages = [];
+
+ private Paramore.Brighter.MessagingGateway.NATS.NatsSubscription? _subscription;
+ private Paramore.Brighter.MessagingGateway.NATS.NatsPublication? _publication;
+
+ private IAmAMessageProducerSync? _producer;
+ private IAmAChannelSync? _channel;
+
+ public WhenPostingAMessageViaTheMessagingGatewayShouldBeReceived()
+ {
+ _messageGatewayProvider = new Paramore.Brighter.NATS.Tests.MessagingGateway.NatsMessageGatewayProvider();
+ _messageBuilder = new DefaultMessageBuilder();
+ _messageAssertion = new DefaultMessageAssertion();
+ }
+
+ public void Dispose()
+ {
+ _messageGatewayProvider.CleanUp(_producer, _channel, _sentMessages);
+ }
+
+ [Fact]
+ public void When_posting_a_message_via_the_messaging_gateway_should_be_received()
+ {
+ // Arrange
+ _publication = _messageGatewayProvider.CreatePublication(_messageGatewayProvider.GetOrCreateRoutingKey());
+ _subscription = _messageGatewayProvider.CreateSubscription(_publication.Topic!,
+ _messageGatewayProvider.GetOrCreateChannelName(),
+ OnMissingChannel.Create);
+
+ _producer = _messageGatewayProvider.CreateProducer(_publication);
+ _channel = _messageGatewayProvider.CreateChannel(_subscription);
+
+ var message = _messageBuilder.SetTopic(_publication.Topic!).Build();
+ _sentMessages.Add(message);
+
+ // Act
+ _producer.Send(message);
+
+ var received = _channel.Receive(TimeSpan.FromMilliseconds(4000));
+
+ // Assert
+ Assert.NotEqual(MessageType.MT_NONE, received.Header.MessageType);
+ _messageAssertion.Assert(message, received);
+ }
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_requeuing_a_failed_message_should_receive_message_again.cs b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_requeuing_a_failed_message_should_receive_message_again.cs
new file mode 100644
index 0000000000..5b448f6f30
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_requeuing_a_failed_message_should_receive_message_again.cs
@@ -0,0 +1,80 @@
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+using System;
+using System.Collections.Generic;
+using System.Threading;
+
+using Xunit;
+
+namespace Paramore.Brighter.NATS.Tests.MessagingGateway.Reactor;
+
+[Trait("Category", "NATS")]
+[Collection("NATSMessagingGateway")]
+public class WhenRequeuingAFailedMessageShouldReceiveMessageAgain : IDisposable
+{
+ private readonly IAmAMessageGatewayReactorProvider _messageGatewayProvider;
+ private readonly IAmAMessageBuilder _messageBuilder;
+ private readonly IAmAMessageAssertion _messageAssertion;
+
+ private List _sentMessages = [];
+
+ private Paramore.Brighter.MessagingGateway.NATS.NatsSubscription? _subscription;
+ private Paramore.Brighter.MessagingGateway.NATS.NatsPublication? _publication;
+
+ private IAmAMessageProducerSync? _producer = null;
+ private IAmAChannelSync? _channel = null;
+
+ public WhenRequeuingAFailedMessageShouldReceiveMessageAgain()
+ {
+ _messageGatewayProvider = new Paramore.Brighter.NATS.Tests.MessagingGateway.NatsMessageGatewayProvider();
+ _messageBuilder = new DefaultMessageBuilder();
+ _messageAssertion = new DefaultMessageAssertion();
+ }
+
+ public void Dispose()
+ {
+ _messageGatewayProvider.CleanUp(_producer, _channel, _sentMessages);
+ }
+
+ [Fact]
+ public void When_requeuing_a_failed_message_should_receive_message_again()
+ {
+ // Arrange
+ _publication = _messageGatewayProvider.CreatePublication(_messageGatewayProvider.GetOrCreateRoutingKey());
+ _subscription = _messageGatewayProvider.CreateSubscription(_publication.Topic!,
+ _messageGatewayProvider.GetOrCreateChannelName(),
+ OnMissingChannel.Create);
+
+ _producer = _messageGatewayProvider.CreateProducer(_publication);
+ _channel = _messageGatewayProvider.CreateChannel(_subscription);
+
+ var message = _messageBuilder.SetTopic(_publication.Topic!).Build();
+ _sentMessages.Add(message);
+
+ _producer.Send(message);
+
+ // Act
+ var received = _channel.Receive(TimeSpan.FromMilliseconds(4000));
+ Assert.NotEqual(MessageType.MT_NONE, received.Header.MessageType);
+
+ _channel.Requeue(received);
+
+
+
+ // Retry receiving in case the requeued message is not immediately available
+ var requeued = new Message();
+ for (var i = 0; i < 10; i++)
+ {
+ requeued = _channel.Receive(TimeSpan.FromMilliseconds(4000));
+ if (requeued.Header.MessageType != MessageType.MT_NONE)
+ {
+ break;
+ }
+ }
+
+ // Assert
+ Assert.NotEqual(MessageType.MT_NONE, requeued.Header.MessageType);
+ _messageAssertion.Assert(message, requeued);
+ }
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_sending_a_message_should_propagate_activity_context.cs b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_sending_a_message_should_propagate_activity_context.cs
new file mode 100644
index 0000000000..57c926572d
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/Generated/Reactor/When_sending_a_message_should_propagate_activity_context.cs
@@ -0,0 +1,88 @@
+//
+// This file is auto-generated by Paramore.Brighter.Test.Generator
+//
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+
+using OpenTelemetry;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+using Xunit;
+
+namespace Paramore.Brighter.NATS.Tests.MessagingGateway.Reactor;
+
+[Trait("Category", "NATS")]
+[Collection("NATSMessagingGateway")]
+public class WhenSendingAMessageShouldPropagateActivityContext : IDisposable
+{
+ private readonly IAmAMessageGatewayReactorProvider _messageGatewayProvider;
+ private readonly IAmAMessageBuilder _messageBuilder;
+
+ private List _sentMessages = [];
+
+ private Paramore.Brighter.MessagingGateway.NATS.NatsSubscription? _subscription;
+ private Paramore.Brighter.MessagingGateway.NATS.NatsPublication? _publication;
+
+ private IAmAMessageProducerSync? _producer;
+ private IAmAChannelSync? _channel;
+
+ public WhenSendingAMessageShouldPropagateActivityContext()
+ {
+ _messageGatewayProvider = new Paramore.Brighter.NATS.Tests.MessagingGateway.NatsMessageGatewayProvider();
+ _messageBuilder = new DefaultMessageBuilder();
+ }
+
+ public void Dispose()
+ {
+ _messageGatewayProvider.CleanUp(_producer, _channel, _sentMessages);
+ }
+
+ [Fact]
+ public void When_sending_a_message_should_propagate_activity_context()
+ {
+ // Arrange
+ var builder = Sdk.CreateTracerProviderBuilder();
+ var exportedActivities = new List();
+
+ using var tracerProvider = builder
+ .AddSource("Paramore.Brighter.Tests", "Paramore.Brighter")
+ .ConfigureResource(r => r.AddService("message-producer-tracer"))
+ .AddInMemoryExporter(exportedActivities)
+ .Build();
+
+ using var parentActivity = new ActivitySource("Paramore.Brighter.Tests").StartActivity("MessageProducerTests");
+ parentActivity!.TraceStateString = "brighter=00f067aa0ba902b7,congo=t61rcWkgMzE";
+
+ Baggage.SetBaggage("key", "value");
+ Baggage.SetBaggage("key2", "value2");
+
+ _publication = _messageGatewayProvider.CreatePublication(_messageGatewayProvider.GetOrCreateRoutingKey());
+ _subscription = _messageGatewayProvider.CreateSubscription(_publication.Topic!,
+ _messageGatewayProvider.GetOrCreateChannelName(),
+ OnMissingChannel.Create);
+
+ _producer = _messageGatewayProvider.CreateProducer(_publication);
+ _channel = _messageGatewayProvider.CreateChannel(_subscription);
+ _producer.Span = parentActivity;
+
+ var message = _messageBuilder
+ .SetTopic(_publication.Topic!)
+ .SetTraceParent(null)
+ .SetTraceState(null)
+ .SetBaggage(new Paramore.Brighter.Observability.Baggage())
+ .Build();
+ _sentMessages.Add(message);
+
+ // Act
+ _producer.Send(message);
+
+ tracerProvider.ForceFlush();
+
+ // Assert
+ Assert.NotNull(message.Header.TraceParent);
+ Assert.Equal("brighter=00f067aa0ba902b7,congo=t61rcWkgMzE", message.Header.TraceState);
+ Assert.Equal("key=value,key2=value2", message.Header.Baggage.ToString());
+ }
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/NatsMessageGatewayProvider.cs b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/NatsMessageGatewayProvider.cs
new file mode 100644
index 0000000000..a06e62f195
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/MessagingGateway/NatsMessageGatewayProvider.cs
@@ -0,0 +1,177 @@
+#region Licence
+
+/* The MIT License (MIT)
+Copyright © 2014 Ian Cooper
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE. */
+
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using NATS.Client.Core;
+using NATS.Client.JetStream;
+using Paramore.Brighter.MessagingGateway.NATS;
+using Paramore.Brighter.NATS.Tests.TestDoubles;
+using Paramore.Brighter.Observability;
+using Paramore.Brighter.Tasks;
+
+namespace Paramore.Brighter.NATS.Tests.MessagingGateway;
+
+///
+/// Messaging gateway provider for the generated core NATS test suite. Shares a single
+/// between producer and consumer so that the subscription
+/// is always registered with the server before any publish on the same subject.
+///
+public class NatsMessageGatewayProvider
+ : Proactor.IAmAMessageGatewayProactorProvider,
+ Reactor.IAmAMessageGatewayReactorProvider
+{
+ private readonly NatsConnection _connection;
+ private readonly NatsJSContext _jetStream;
+
+ public NatsMessageGatewayProvider()
+ {
+ _connection = new NatsConnection();
+ _jetStream = new NatsJSContext(_connection);
+ }
+
+ public RoutingKey GetOrCreateRoutingKey([CallerMemberName] string? testName = null)
+ {
+ return new RoutingKey($"gen.nats.{Guid.NewGuid().ToString("N")[..8]}");
+ }
+
+ public ChannelName GetOrCreateChannelName([CallerMemberName] string? testName = null)
+ {
+ return new ChannelName($"gen.nats.{Guid.NewGuid().ToString("N")[..8]}");
+ }
+
+ public NatsPublication CreatePublication(
+ RoutingKey routingKey,
+ OnMissingChannel makeChannels = OnMissingChannel.Create
+ )
+ {
+ return new NatsPublication { Topic = routingKey, MakeChannels = makeChannels };
+ }
+
+ public NatsSubscription CreateSubscription(
+ RoutingKey routingKey,
+ ChannelName channelName,
+ OnMissingChannel makeChannel,
+ bool setupDeadLetterQueue = false
+ )
+ {
+ // Core NATS has no queue infrastructure: the channel name is the subject the consumer
+ // subscribes to, so it must match the routing key the producer publishes to.
+ return new NatsSubscription(
+ subscriptionName: new SubscriptionName(Guid.NewGuid().ToString()),
+ channelName: new ChannelName(routingKey.Value),
+ routingKey: routingKey,
+ messagePumpType: MessagePumpType.Proactor,
+ makeChannels: makeChannel
+ );
+ }
+
+ public IAmAMessageProducerSync CreateProducer(NatsPublication publication)
+ {
+ return new NatsMessageProducer(_connection, publication, InstrumentationOptions.All);
+ }
+
+ public Task CreateProducerAsync(
+ NatsPublication publication,
+ CancellationToken cancellationToken = default
+ )
+ {
+ return Task.FromResult(
+ new NatsMessageProducer(_connection, publication, InstrumentationOptions.All)
+ );
+ }
+
+ public IAmAChannelSync CreateChannel(NatsSubscription subscription)
+ {
+ return new NatsChannelFactory(new NatsMessageConsumerFactory(_connection, _jetStream))
+ .CreateSyncChannel(subscription);
+ }
+
+ public Task CreateChannelAsync(
+ NatsSubscription subscription,
+ CancellationToken cancellationToken = default
+ )
+ {
+ return new NatsChannelFactory(new NatsMessageConsumerFactory(_connection, _jetStream))
+ .CreateAsyncChannelAsync(subscription, cancellationToken);
+ }
+
+ public Message GetMessageFromDeadLetterQueue(NatsSubscription subscription)
+ {
+ throw new NotSupportedException("Core NATS has no dead letter queue support");
+ }
+
+ public Task GetMessageFromDeadLetterQueueAsync(
+ NatsSubscription subscription,
+ CancellationToken cancellationToken = default
+ )
+ {
+ throw new NotSupportedException("Core NATS has no dead letter queue support");
+ }
+
+ public void CleanUp(
+ IAmAMessageProducerSync? producer,
+ IAmAChannelSync? channel,
+ IEnumerable messages
+ )
+ {
+ if (channel != null)
+ {
+ try { channel.Purge(); } catch { /* best effort */ }
+ try { channel.Dispose(); } catch { /* best effort */ }
+ }
+
+ try { producer?.Dispose(); } catch { /* best effort */ }
+
+ try
+ {
+ BrighterAsyncContext.Run(async () => await _connection.DisposeAsync());
+ }
+ catch { /* best effort */ }
+ }
+
+ public async Task CleanUpAsync(
+ IAmAMessageProducerAsync? producer,
+ IAmAChannelAsync? channel,
+ IEnumerable messages
+ )
+ {
+ if (channel != null)
+ {
+ try { await channel.PurgeAsync(); } catch { /* best effort */ }
+ try { channel.Dispose(); } catch { /* best effort */ }
+ }
+
+ if (producer != null)
+ {
+ try { await producer.DisposeAsync(); } catch { /* best effort */ }
+ }
+
+ try { await _connection.DisposeAsync(); } catch { /* best effort */ }
+ }
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/Paramore.Brighter.NATS.Tests.csproj b/tests/Paramore.Brighter.NATS.Tests/Paramore.Brighter.NATS.Tests.csproj
new file mode 100644
index 0000000000..bbdf279af9
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/Paramore.Brighter.NATS.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ $(BrighterTestTargetFrameworks)
+ false
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
diff --git a/tests/Paramore.Brighter.NATS.Tests/TestDoubles/MyCommand.cs b/tests/Paramore.Brighter.NATS.Tests/TestDoubles/MyCommand.cs
new file mode 100644
index 0000000000..0e2aa95b81
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/TestDoubles/MyCommand.cs
@@ -0,0 +1,33 @@
+#region Licence
+
+/* The MIT License (MIT)
+Copyright © 2014 Ian Cooper
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE. */
+
+#endregion
+
+using System;
+
+namespace Paramore.Brighter.NATS.Tests.TestDoubles;
+
+internal sealed class MyCommand() : Command(Guid.NewGuid())
+{
+ public string Value { get; set; } = string.Empty;
+}
diff --git a/tests/Paramore.Brighter.NATS.Tests/test-configuration.json b/tests/Paramore.Brighter.NATS.Tests/test-configuration.json
new file mode 100644
index 0000000000..ea8522701f
--- /dev/null
+++ b/tests/Paramore.Brighter.NATS.Tests/test-configuration.json
@@ -0,0 +1,17 @@
+{
+ "Namespace": "Paramore.Brighter.NATS.Tests",
+ "MessagingGateway": {
+ "Publication": "Paramore.Brighter.MessagingGateway.NATS.NatsPublication",
+ "Subscription": "Paramore.Brighter.MessagingGateway.NATS.NatsSubscription",
+ "MessageGatewayProvider": "Paramore.Brighter.NATS.Tests.MessagingGateway.NatsMessageGatewayProvider",
+ "Category": "NATS",
+ "CollectionName": "NATSMessagingGateway",
+ "ReceiveMessageTimeoutInMilliseconds": 4000,
+ "HasSupportToPublishConfirmation": false,
+ "HasSupportToDeadLetterQueue": false,
+ "HasSupportToDelayedMessages": false,
+ "HasSupportToValidateBrokerExistence": false,
+ "HasSupportToRequeue": true,
+ "HasSupportToValidateInfrastructure": false
+ }
+}