diff --git a/Obvs.NATS.Tests/Hosting/HostingMessages.cs b/Obvs.NATS.Tests/Hosting/HostingMessages.cs new file mode 100644 index 0000000..e1e9a99 --- /dev/null +++ b/Obvs.NATS.Tests/Hosting/HostingMessages.cs @@ -0,0 +1,30 @@ +using Obvs.Types; + +namespace Obvs.NATS.Tests.Hosting +{ + public interface IOrderMessage : IMessage { } + + public class PlaceOrder : IOrderMessage, ICommand + { + public string OrderId { get; set; } + } + + public class OrderPlaced : IOrderMessage, IEvent + { + public string OrderId { get; set; } + } + + public class GetQuote : IOrderMessage, IRequest + { + public string Sku { get; set; } + public string RequestId { get; set; } + public string RequesterId { get; set; } + } + + public class Quote : IOrderMessage, IResponse + { + public decimal Price { get; set; } + public string RequestId { get; set; } + public string RequesterId { get; set; } + } +} diff --git a/Obvs.NATS.Tests/Hosting/TestNatsHosting.cs b/Obvs.NATS.Tests/Hosting/TestNatsHosting.cs new file mode 100644 index 0000000..d4da6fc --- /dev/null +++ b/Obvs.NATS.Tests/Hosting/TestNatsHosting.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Obvs.Hosting; +using Obvs.Serialization.Json; +using Xunit; + +namespace Obvs.NATS.Tests.Hosting +{ + // End to end through AddObvs and UseNats against a real server (NATS_URL, default nats://localhost:4222). + // Each test uses its own service name, so its subjects don't meet the other tests'. + public class TestNatsHosting + { + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + + [Fact, Trait("Category", "Explicit")] + public async Task ShouldSendCommandsAndPublishEventsOverNats() + { + using var host = await StartHostAsync("obvs-nats-commands-events"); + var bus = host.Services.GetRequiredService(); + + var command = bus.Commands.OfType().FirstAsync().ToTask(); + await bus.SendAsync(new PlaceOrder { OrderId = "o-1" }); + Assert.Equal("o-1", (await command.WaitAsync(Timeout, TestContext.Current.CancellationToken)).OrderId); + + var placed = bus.Events.OfType().FirstAsync().ToTask(); + await bus.PublishAsync(new OrderPlaced { OrderId = "o-1" }); + Assert.Equal("o-1", (await placed.WaitAsync(Timeout, TestContext.Current.CancellationToken)).OrderId); + } + + [Fact, Trait("Category", "Explicit")] + public async Task ShouldReturnResponsesOverNats() + { + using var host = await StartHostAsync("obvs-nats-requests", service => service + .HandleRequest((request, context, ct) => Task.FromResult(new Quote { Price = 9.99m }))); + + var quote = await host.Services.GetRequiredService() + .RequestAsync(new GetQuote { Sku = "sku-1" }, Timeout, TestContext.Current.CancellationToken); + + Assert.Equal(9.99m, quote.Price); + } + + [Fact, Trait("Category", "Explicit")] + public async Task ShouldKeepWorkingWhenAnotherHostInTheProcessStops() + { + // Two hosts in one process, as tests have: stopping one mustn't close the other's connection + using var hostA = await StartHostAsync("obvs-nats-two-hosts"); + var hostB = await StartHostAsync("obvs-nats-two-hosts"); + await hostB.StopAsync(TestContext.Current.CancellationToken); + hostB.Dispose(); + var busA = hostA.Services.GetRequiredService(); + + var command = busA.Commands.OfType().FirstAsync().ToTask(); + await busA.SendAsync(new PlaceOrder { OrderId = "o-1" }); + + Assert.Equal("o-1", (await command.WaitAsync(Timeout, TestContext.Current.CancellationToken)).OrderId); + } + + [Fact, Trait("Category", "Explicit")] + public async Task ShouldNotDeadlockWhenASubscriberDisposesTheBus() + { + var host = await StartHostAsync("obvs-nats-dispose-in-callback"); + var bus = host.Services.GetRequiredService(); + var disposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = bus.Commands.OfType().Subscribe(_ => + { + host.Dispose(); + disposed.SetResult(true); + }); + + await bus.SendAsync(new PlaceOrder { OrderId = "o-1" }); + + Assert.True(await disposed.Task.WaitAsync(Timeout, TestContext.Current.CancellationToken)); + } + + [Fact, Trait("Category", "Explicit")] + public async Task ShouldContinueTheSendersTraceAcrossNats() + { + const string serviceName = "obvs-nats-tracing"; + using var activities = new ActivityRecorder(); + using var receiver = await StartHostAsync(serviceName, service => service.AsServer() + .HandleCommand((command, context, ct) => Task.CompletedTask)); + using var sender = await StartHostAsync(serviceName, service => service.AsClient()); + + await sender.Services.GetRequiredService().SendAsync(new PlaceOrder { OrderId = "o-1" }); + + var send = await WaitForAsync(() => activities.For(serviceName).SingleOrDefault(activity => activity.DisplayName == $"send {serviceName}")); + var process = await WaitForAsync(() => activities.For(serviceName).SingleOrDefault(activity => activity.DisplayName == $"process {serviceName}")); + Assert.Equal(send.TraceId, process.TraceId); + Assert.Equal(send.SpanId, process.ParentSpanId); + } + + [Fact, Trait("Category", "Explicit")] + public async Task ShouldCarryHeadersTheAppSetsOverNats() + { + const string serviceName = "obvs-nats-headers"; + var received = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + using var receiver = await StartHostAsync(serviceName, service => service.AsServer() + .HandleCommand((command, context, ct) => + { + received.TrySetResult(ObvsMessageHeaders.Get(command)); + return Task.CompletedTask; + })); + using var sender = await StartHostAsync(serviceName, service => service.AsClient()); + var placeOrder = new PlaceOrder { OrderId = "o-1" }; + ObvsMessageHeaders.Set(placeOrder, new Dictionary { ["x-tenant"] = "acme" }); + + await sender.Services.GetRequiredService().SendAsync(placeOrder); + + var headers = await received.Task.WaitAsync(Timeout, TestContext.Current.CancellationToken); + Assert.Equal("acme", headers["x-tenant"]); + } + + private static async Task StartHostAsync(string serviceName, Action configure = null) + { + var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { DisableDefaults = true }); + builder.Services.AddObvs(obvs => obvs.AddService(serviceName, service => + { + service + .UseNats(options => options.Url = TestBroker.Url) + .SerializedWith(new JsonMessageSerializer(), new JsonMessageDeserializerFactory(typeof(JsonMessageDeserializer<>))); + configure?.Invoke(service); + })); + var host = builder.Build(); + await host.StartAsync(TestContext.Current.CancellationToken); + return host; + } + + private static async Task WaitForAsync(Func find) where T : class + { + var deadline = DateTime.UtcNow + Timeout; + T found; + while ((found = find()) == null && DateTime.UtcNow < deadline) + { + await Task.Delay(20, TestContext.Current.CancellationToken); + } + return found ?? throw new TimeoutException($"The expected {typeof(T).Name} wasn't recorded in time."); + } + + private sealed class ActivityRecorder : IDisposable + { + private readonly ConcurrentQueue _stopped = new ConcurrentQueue(); + private readonly ActivityListener _listener; + + public ActivityRecorder() + { + _listener = new ActivityListener + { + ShouldListenTo = source => source.Name == ObvsTelemetry.ActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => _stopped.Enqueue(activity), + }; + ActivitySource.AddActivityListener(_listener); + } + + public IReadOnlyList For(string serviceName) + { + return _stopped.Where(activity => (activity.GetTagItem("messaging.destination.name") as string) == serviceName).ToList(); + } + + public void Dispose() => _listener.Dispose(); + } + } +} diff --git a/Obvs.NATS.Tests/Hosting/TestUseNats.cs b/Obvs.NATS.Tests/Hosting/TestUseNats.cs new file mode 100644 index 0000000..74ae09f --- /dev/null +++ b/Obvs.NATS.Tests/Hosting/TestUseNats.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Obvs.Hosting; +using Obvs.Serialization.Json; +using Xunit; + +namespace Obvs.NATS.Tests.Hosting +{ + // Configuration and validation; none of these connect to a server + public class TestUseNats + { + [Fact] + public void ShouldBindOptionsFromConfiguration() + { + var configuration = Configuration(new Dictionary { ["Obvs:Orders:Nats:Url"] = "nats://nats.example:4222" }); + using var host = BuildHost(services => services.AddObvs(obvs => obvs + .AddService("orders", service => service + .UseNats(configuration.GetSection("Obvs:Orders:Nats")) + .SerializedWith(new JsonMessageSerializer(), new JsonMessageDeserializerFactory(typeof(JsonMessageDeserializer<>)))))); + + Assert.Equal("nats://nats.example:4222", host.Services.GetRequiredService>().Get("orders").Url); + } + + [Fact] + public void ShouldConfigureOptionsInCode() + { + using var host = BuildHost(services => services.AddObvs(obvs => obvs + .AddService("orders", service => service + .UseNats(options => options.Url = "nats://nats.example:4222") + .SerializedWith(new JsonMessageSerializer(), new JsonMessageDeserializerFactory(typeof(JsonMessageDeserializer<>)))))); + + Assert.Equal("nats://nats.example:4222", host.Services.GetRequiredService>().Get("orders").Url); + } + + [Fact] + public async Task ShouldFailToStartWhenTheUrlIsMissingFromConfiguration() + { + var configuration = Configuration(new Dictionary()); + using var host = BuildHost(services => services.AddObvs(obvs => obvs + .AddService("orders", service => service + .UseNats(configuration.GetSection("Obvs:Orders:Nats")) + .SerializedWith(new JsonMessageSerializer(), new JsonMessageDeserializerFactory(typeof(JsonMessageDeserializer<>)))))); + + var exception = await Assert.ThrowsAsync(() => host.StartAsync(TestContext.Current.CancellationToken)); + + var error = Assert.Single(exception.Errors); + Assert.Contains("'orders'", error); + Assert.Contains("Obvs:Orders:Nats:Url", error); + } + + [Fact] + public async Task ShouldFailToStartWhenTheUrlIsMissingFromCode() + { + using var host = BuildHost(services => services.AddObvs(obvs => obvs + .AddService("orders", service => service + .UseNats(options => { }) + .SerializedWith(new JsonMessageSerializer(), new JsonMessageDeserializerFactory(typeof(JsonMessageDeserializer<>)))))); + + var exception = await Assert.ThrowsAsync(() => host.StartAsync(TestContext.Current.CancellationToken)); + + var error = Assert.Single(exception.Errors); + Assert.Contains("'orders'", error); + Assert.Contains("Url", error); + } + + [Fact] + public async Task ShouldFailToStartWithoutASerializer() + { + using var host = BuildHost(services => services.AddObvs(obvs => obvs + .AddService("orders", service => service.UseNats(options => options.Url = "nats://nats.example:4222")))); + + var exception = await Assert.ThrowsAsync(() => host.StartAsync(TestContext.Current.CancellationToken)); + + var error = Assert.Single(exception.Errors); + Assert.Contains("'orders'", error); + Assert.Contains("SerializedWith", error); + } + + private static IConfiguration Configuration(IDictionary values) + { + return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + } + + private static IHost BuildHost(Action configure) + { + var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { DisableDefaults = true }); + configure(builder.Services); + return builder.Build(); + } + } +} diff --git a/Obvs.NATS.Tests/Obvs.NATS.Tests.csproj b/Obvs.NATS.Tests/Obvs.NATS.Tests.csproj index 188814a..fb31c56 100644 --- a/Obvs.NATS.Tests/Obvs.NATS.Tests.csproj +++ b/Obvs.NATS.Tests/Obvs.NATS.Tests.csproj @@ -5,6 +5,7 @@ + diff --git a/Obvs.NATS/MessagePublisher.cs b/Obvs.NATS/MessagePublisher.cs index 4470cdc..76b0355 100644 --- a/Obvs.NATS/MessagePublisher.cs +++ b/Obvs.NATS/MessagePublisher.cs @@ -40,10 +40,27 @@ public async Task PublishAsync(TMessage message) using (var stream = new MemoryStream()) { ProtoBuf.Serializer.Serialize(stream, wrapper); - await _connection.PublishAsync(subject, stream.ToArray()).ConfigureAwait(false); + await _connection.PublishAsync(subject, stream.ToArray(), GetHeaders(message)).ConfigureAwait(false); } } + // The message's Obvs headers (such as trace context) as NATS headers, which v6 receivers ignore + private static NatsHeaders GetHeaders(TMessage message) + { + var headers = ObvsMessageHeaders.Get(message); + if (headers.Count == 0) + { + return null; + } + + var natsHeaders = new NatsHeaders(); + foreach (var header in headers) + { + natsHeaders[header.Key] = header.Value; + } + return natsHeaders; + } + public void Dispose() { } diff --git a/Obvs.NATS/MessageSource.cs b/Obvs.NATS/MessageSource.cs index 64f4da1..6dc7c18 100644 --- a/Obvs.NATS/MessageSource.cs +++ b/Obvs.NATS/MessageSource.cs @@ -68,7 +68,9 @@ private async Task ReceiveAsync(INatsSub subscription, IObserver subscription, IObserver(); + foreach (var header in natsHeaders) + { + headers[header.Key] = header.Value.ToString(); + } + ObvsMessageHeaders.Set(message, headers); + } + private bool MatchesFilter(MessageWrapper wrapper) { return _filter == null || _filter(wrapper.Properties); diff --git a/Obvs.NATS/NatsOptions.cs b/Obvs.NATS/NatsOptions.cs new file mode 100644 index 0000000..5f9e79f --- /dev/null +++ b/Obvs.NATS/NatsOptions.cs @@ -0,0 +1,12 @@ +namespace Obvs.NATS +{ + /// + /// A service's NATS settings. Each service has its own, as named options keyed by the service's + /// name: IOptionsMonitor<NatsOptions>.Get("orders"). + /// + public sealed class NatsOptions + { + /// The server to connect to, for example nats://localhost:4222. Required. + public string Url { get; set; } + } +} diff --git a/Obvs.NATS/NatsTransportExtensions.cs b/Obvs.NATS/NatsTransportExtensions.cs new file mode 100644 index 0000000..cae2a9f --- /dev/null +++ b/Obvs.NATS/NatsTransportExtensions.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Obvs.Configuration; +using Obvs.NATS; +using Obvs.NATS.Configuration; +using Obvs.Types; + +// In the builder's namespace, so UseNats() is found without an extra using +namespace Obvs.Hosting +{ + public static class NatsTransportExtensions + { + /// + /// Carries this service's messages over NATS, with settings bound from , + /// for example builder.Configuration.GetSection("Obvs:Orders:Nats"). Each service on each bus gets + /// its own connection. The service also needs a serializer: call SerializedWith(...). + /// + public static ObvsServiceBuilder UseNats(this ObvsServiceBuilder service, IConfiguration configuration) + { + if (service == null) + { + throw new ArgumentNullException(nameof(service)); + } + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + service.Services.AddOptions(service.Name).Bind(configuration); + var urlKey = configuration is IConfigurationSection section ? section.Path + ":Url" : "Url"; + return UseNats(service, $"configuration key {urlKey}"); + } + + /// + /// Carries this service's messages over NATS, with settings set in code. + /// The service also needs a serializer: call SerializedWith(...). + /// + public static ObvsServiceBuilder UseNats(this ObvsServiceBuilder service, Action configure) + { + if (service == null) + { + throw new ArgumentNullException(nameof(service)); + } + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + service.Services.AddOptions(service.Name).Configure(configure); + return UseNats(service, "options.Url in UseNats(...)"); + } + + private static ObvsServiceBuilder UseNats(ObvsServiceBuilder service, string whereToSetUrl) + { + return service + .ValidateWith(serviceProvider => Validate(service, GetOptions(serviceProvider, service), whereToSetUrl)) + .UseEndpointProvider(serviceProvider => CreateProvider(service, GetOptions(serviceProvider, service))); + } + + private static NatsOptions GetOptions(IServiceProvider serviceProvider, ObvsServiceBuilder service) + { + return serviceProvider.GetRequiredService>().Get(service.Name); + } + + private static IEnumerable Validate(ObvsServiceBuilder service, NatsOptions options, string whereToSetUrl) + { + if (string.IsNullOrWhiteSpace(options.Url)) + { + yield return $"Service '{service.Name}' has no NATS server address. Set {whereToSetUrl}, for example nats://localhost:4222."; + } + if (service.Serializer == null || service.DeserializerFactory == null) + { + yield return $"Service '{service.Name}' uses NATS but has no serializer. Call SerializedWith(...) on it inside AddService(\"{service.Name}\", ...)."; + } + } + + // The provider is generic in the service's message type, which the builder only knows at run time. + // Connections aren't shared: v6's shared connections are process-wide, so one host stopping would + // close another's. + private static IServiceEndpointProvider CreateProvider(ObvsServiceBuilder service, NatsOptions options) + { + var settings = new NatsEndpointSettings { ServiceName = service.Name }; + settings.Configure((NatsEndpointSettings.BrokerConnection connection) => + { + connection.Url = options.Url; + connection.IsShared = false; + }); + + var providerType = typeof(NatsServiceEndpointProvider<,,,,,>).MakeGenericType( + service.ServiceMessageType, typeof(IMessage), typeof(ICommand), typeof(IEvent), typeof(IRequest), typeof(IResponse)); + + return (IServiceEndpointProvider)Activator.CreateInstance(providerType, + settings, service.Serializer, service.DeserializerFactory, null, null, null, null); + } + } +} diff --git a/Obvs.NATS/Obvs.NATS.csproj b/Obvs.NATS/Obvs.NATS.csproj index a5ad1c4..5f1e76f 100644 --- a/Obvs.NATS/Obvs.NATS.csproj +++ b/Obvs.NATS/Obvs.NATS.csproj @@ -12,6 +12,7 @@ NATS transport support for Obvs +