diff --git a/src/NATS.Server/NatsOptions.cs b/src/NATS.Server/NatsOptions.cs index a31ede2..c9978a2 100644 --- a/src/NATS.Server/NatsOptions.cs +++ b/src/NATS.Server/NatsOptions.cs @@ -97,5 +97,8 @@ public sealed class NatsOptions // Per-subsystem log level overrides (namespace -> level) public Dictionary? LogOverrides { get; set; } + // Subject mapping / transforms (source pattern -> destination template) + public Dictionary? SubjectMappings { get; set; } + public bool HasTls => TlsCert != null && TlsKey != null; } diff --git a/src/NATS.Server/NatsServer.cs b/src/NATS.Server/NatsServer.cs index b8dada6..2c2f0e2 100644 --- a/src/NATS.Server/NatsServer.cs +++ b/src/NATS.Server/NatsServer.cs @@ -32,6 +32,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable private readonly Account _systemAccount; private readonly SslServerAuthenticationOptions? _sslOptions; private readonly TlsRateLimiter? _tlsRateLimiter; + private readonly SubjectTransform[] _subjectTransforms; private Socket? _listener; private MonitorServer? _monitorServer; private ulong _nextClientId; @@ -297,6 +298,27 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable _tlsRateLimiter = new TlsRateLimiter(options.TlsRateLimit); } + // Compile subject transforms + if (options.SubjectMappings is { Count: > 0 }) + { + var transforms = new List(); + foreach (var (source, dest) in options.SubjectMappings) + { + var t = SubjectTransform.Create(source, dest); + if (t != null) + transforms.Add(t); + else + _logger.LogWarning("Invalid subject mapping: {Source} -> {Dest}", source, dest); + } + _subjectTransforms = transforms.ToArray(); + if (_subjectTransforms.Length > 0) + _logger.LogInformation("Compiled {Count} subject transform(s)", _subjectTransforms.Length); + } + else + { + _subjectTransforms = []; + } + BuildCachedInfo(); } @@ -512,6 +534,20 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory headers, ReadOnlyMemory payload, NatsClient sender) { + // Apply subject transforms + if (_subjectTransforms.Length > 0) + { + foreach (var transform in _subjectTransforms) + { + var mapped = transform.Apply(subject); + if (mapped != null) + { + subject = mapped; + break; // First matching transform wins + } + } + } + var subList = sender.Account?.SubList ?? _globalAccount.SubList; var result = subList.Match(subject); var delivered = false; diff --git a/tests/NATS.Server.Tests/SubjectTransformIntegrationTests.cs b/tests/NATS.Server.Tests/SubjectTransformIntegrationTests.cs new file mode 100644 index 0000000..c193c5a --- /dev/null +++ b/tests/NATS.Server.Tests/SubjectTransformIntegrationTests.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests; + +public class SubjectTransformIntegrationTests +{ + [Fact] + public void Server_compiles_subject_mappings() + { + var options = new NatsOptions + { + SubjectMappings = new Dictionary + { + ["src.*"] = "dest.{{wildcard(1)}}", + ["orders.*.*"] = "processed.{{wildcard(2)}}.{{wildcard(1)}}", + }, + }; + using var server = new NatsServer(options, NullLoggerFactory.Instance); + + // Server should have started without errors (transforms compiled) + server.Port.ShouldBe(4222); + } + + [Fact] + public void Server_ignores_null_subject_mappings() + { + var options = new NatsOptions { SubjectMappings = null }; + using var server = new NatsServer(options, NullLoggerFactory.Instance); + server.Port.ShouldBe(4222); + } + + [Fact] + public void Server_ignores_empty_subject_mappings() + { + var options = new NatsOptions { SubjectMappings = new Dictionary() }; + using var server = new NatsServer(options, NullLoggerFactory.Instance); + server.Port.ShouldBe(4222); + } + + [Fact] + public void Server_logs_warning_for_invalid_mapping() + { + var options = new NatsOptions + { + SubjectMappings = new Dictionary + { + [""] = "dest", // invalid empty source becomes ">" which is valid + }, + }; + using var server = new NatsServer(options, NullLoggerFactory.Instance); + // Should not throw, just log a warning and skip + server.Port.ShouldBe(4222); + } + + [Fact] + public void SubjectTransform_applies_first_matching_rule() + { + // Unit test the transform application logic directly + var t1 = SubjectTransform.Create("src.*", "dest.{{wildcard(1)}}"); + var t2 = SubjectTransform.Create("src.*", "other.{{wildcard(1)}}"); + t1.ShouldNotBeNull(); + t2.ShouldNotBeNull(); + + var transforms = new[] { t1, t2 }; + string subject = "src.hello"; + + // Apply transforms -- first match wins + foreach (var transform in transforms) + { + var mapped = transform.Apply(subject); + if (mapped != null) + { + subject = mapped; + break; + } + } + + subject.ShouldBe("dest.hello"); + } + + [Fact] + public void SubjectTransform_non_matching_subject_unchanged() + { + var t = SubjectTransform.Create("src.*", "dest.{{wildcard(1)}}"); + t.ShouldNotBeNull(); + + var result = t.Apply("other.hello"); + result.ShouldBeNull(); // No match + } +}