diff --git a/.github/workflows/test_linux.yml b/.github/workflows/test_linux.yml index b0e2c811d..0cae81f63 100644 --- a/.github/workflows/test_linux.yml +++ b/.github/workflows/test_linux.yml @@ -66,6 +66,11 @@ jobs: cd tests/NATS.Client.CoreUnit.Tests dotnet test -c Release --no-build + - name: Test Abstractions + run: | + cd tests/NATS.Client.Abstractions.Tests + dotnet test -c Release --no-build + - name: Test JetStream run: | killall nats-server 2> /dev/null | echo -n diff --git a/.github/workflows/test_windows.yml b/.github/workflows/test_windows.yml index 00a0a08b2..44325dbc1 100644 --- a/.github/workflows/test_windows.yml +++ b/.github/workflows/test_windows.yml @@ -78,6 +78,11 @@ jobs: cd tests/NATS.Client.CoreUnit.Tests dotnet test -c Release --no-build + - name: Test Abstractions + run: | + cd tests/NATS.Client.Abstractions.Tests + dotnet test -c Release --no-build + - name: Test JetStream run: | tasklist | grep -i nats-server && taskkill -F -IM nats-server.exe diff --git a/NATS.Net.slnx b/NATS.Net.slnx index 53b3565f4..1dca45ac9 100644 --- a/NATS.Net.slnx +++ b/NATS.Net.slnx @@ -71,6 +71,7 @@ + diff --git a/tests/NATS.Client.Abstractions.Tests/AbstractionsBoundaryTest.cs b/tests/NATS.Client.Abstractions.Tests/AbstractionsBoundaryTest.cs new file mode 100644 index 000000000..be1ab63e4 --- /dev/null +++ b/tests/NATS.Client.Abstractions.Tests/AbstractionsBoundaryTest.cs @@ -0,0 +1,119 @@ +using System.Buffers; +using System.Text; +using Microsoft.Extensions.Primitives; +using NATS.Client.Core; + +namespace NATS.Client.Abstractions.Tests; + +// Everything here is written against NATS.Client.Abstractions only (see the csproj: no Core +// reference). It stands in for a third-party adapter such as the CloudEvents SDK from issue #851, +// proving that consuming code can deconstruct a received message and construct an outgoing one +// using just the envelope (NatsMsgContext / INatsHeaders) and the serializer interfaces. +public class AbstractionsBoundaryTest +{ + [Fact] + public void Deserialize_with_context_reads_envelope_and_payload() + { + var headers = new TestHeaders { ["ce-type"] = "com.example.order.created" }; + var context = new NatsMsgContext("orders.new", replyTo: "_INBOX.reply", headers: headers); + var payload = new ReadOnlySequence(Encoding.UTF8.GetBytes("order-123")); + + INatsDeserialize deserializer = new CloudEventDeserializer(); + var result = deserializer.Deserialize(payload, context); + + result.Should().NotBeNull(); + result!.Source.Should().Be("orders.new"); + result.Type.Should().Be("com.example.order.created"); + result.Data.Should().Be("order-123"); + } + + [Fact] + public void Serialize_writes_payload_through_the_interface() + { + INatsSerialize serializer = new CloudEventDeserializer(); + var writer = new TestBufferWriter(); + + serializer.Serialize(writer, new CloudEventLike("orders.new", "com.example.order.created", "order-123")); + + Encoding.UTF8.GetString(writer.ToArray()).Should().Be("order-123"); + } + + private sealed class CloudEventLike + { + public CloudEventLike(string source, string? type, string data) + { + Source = source; + Type = type; + Data = data; + } + + public string Source { get; } + + public string? Type { get; } + + public string Data { get; } + } + + // A context-aware (de)serializer backed only by Abstractions types. + private sealed class CloudEventDeserializer : INatsSerializeWithContext, INatsDeserializeWithContext + { + public void Serialize(IBufferWriter bufferWriter, CloudEventLike value) + { + var bytes = Encoding.UTF8.GetBytes(value.Data); + bufferWriter.Write(bytes); + } + + public void Serialize(IBufferWriter bufferWriter, CloudEventLike value, in NatsMsgContext context) => + Serialize(bufferWriter, value); + + public CloudEventLike? Deserialize(in ReadOnlySequence buffer) => + new(source: string.Empty, type: null, data: Encoding.UTF8.GetString(buffer.ToArray())); + + public CloudEventLike? Deserialize(in ReadOnlySequence buffer, in NatsMsgContext context) + { + var type = context.Headers is not null && context.Headers.TryGetValue("ce-type", out var values) + ? values.ToString() + : null; + + return new CloudEventLike( + source: context.Subject, + type: type, + data: Encoding.UTF8.GetString(buffer.ToArray())); + } + } + + // INatsHeaders adds nothing beyond IDictionary, so Dictionary supplies + // every member. A consumer can read headers off a received message with only Abstractions. + private sealed class TestHeaders : Dictionary, INatsHeaders; + + // Minimal IBufferWriter; ArrayBufferWriter is unavailable on net481. + private sealed class TestBufferWriter : IBufferWriter + { + private byte[] _buffer = new byte[256]; + private int _written; + + public void Advance(int count) => _written += count; + + public Memory GetMemory(int sizeHint = 0) + { + Ensure(sizeHint); + return _buffer.AsMemory(_written); + } + + public Span GetSpan(int sizeHint = 0) + { + Ensure(sizeHint); + return _buffer.AsSpan(_written); + } + + public byte[] ToArray() => _buffer.AsSpan(0, _written).ToArray(); + + private void Ensure(int sizeHint) + { + if (_written + Math.Max(sizeHint, 1) <= _buffer.Length) + return; + + Array.Resize(ref _buffer, Math.Max(_buffer.Length * 2, _written + sizeHint)); + } + } +} diff --git a/tests/NATS.Client.Abstractions.Tests/NATS.Client.Abstractions.Tests.csproj b/tests/NATS.Client.Abstractions.Tests/NATS.Client.Abstractions.Tests.csproj new file mode 100644 index 000000000..57710fd3f --- /dev/null +++ b/tests/NATS.Client.Abstractions.Tests/NATS.Client.Abstractions.Tests.csproj @@ -0,0 +1,41 @@ + + + + net8.0;net10.0 + $(TargetFrameworks);net481 + any;win-x86 + enable + false + $(NoWarn);CS8002 + enable + $(MSBuildProjectDirectory)\..\xunit.runsettings + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + +