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 + } +}