diff --git a/src/NATS.Server/Gateways/GatewayCommands.cs b/src/NATS.Server/Gateways/GatewayCommands.cs new file mode 100644 index 0000000..394d5e9 --- /dev/null +++ b/src/NATS.Server/Gateways/GatewayCommands.cs @@ -0,0 +1,87 @@ +using System.Text; + +namespace NATS.Server.Gateways; + +/// +/// Gateway wire protocol command types matching Go's gateway.go format. +/// Go reference: gateway.go:90-120 — gateway protocol constants. +/// +public enum GatewayCommandType +{ + /// Gateway connection info exchange. + Info, + /// Subscribe interest notification. + Sub, + /// Unsubscribe interest notification. + Unsub, + /// Account interest mode change. + Mode, + /// Routed message through gateway. + Msg, + /// Ping keepalive. + Ping, + /// Pong keepalive response. + Pong, +} + +/// +/// Gateway wire protocol byte sequences and formatting helpers matching Go's gateway protocol. +/// Go reference: gateway.go:90-120 — gateway protocol constants and command formatting. +/// +public static class GatewayCommands +{ + // Wire format byte sequences matching Go's gateway protocol + public static readonly byte[] InfoPrefix = "GINFO "u8.ToArray(); + public static readonly byte[] SubPrefix = "GS+ "u8.ToArray(); + public static readonly byte[] UnsubPrefix = "GS- "u8.ToArray(); + public static readonly byte[] ModePrefix = "GMODE "u8.ToArray(); + public static readonly byte[] MsgPrefix = "GMSG "u8.ToArray(); + public static readonly byte[] Ping = "GPING\r\n"u8.ToArray(); + public static readonly byte[] Pong = "GPONG\r\n"u8.ToArray(); + public static readonly byte[] Crlf = "\r\n"u8.ToArray(); + + /// + /// Formats a gateway subscribe command. + /// Wire format: GS+ {account} {subject}\r\n + /// Go reference: gateway.go — sendGatewaySubsToGateway, RS+ propagation. + /// + public static byte[] FormatSub(string account, string subject) + => Encoding.UTF8.GetBytes($"GS+ {account} {subject}\r\n"); + + /// + /// Formats a gateway unsubscribe command. + /// Wire format: GS- {account} {subject}\r\n + /// Go reference: gateway.go — sendGatewayUnsubToGateway, RS- propagation. + /// + public static byte[] FormatUnsub(string account, string subject) + => Encoding.UTF8.GetBytes($"GS- {account} {subject}\r\n"); + + /// + /// Formats a gateway mode change command. + /// Wire format: GMODE {account} {mode}\r\n + /// Mode: "O" for Optimistic (send everything), "I" for Interest-only. + /// Go reference: gateway.go — switchAccountToInterestMode, GMODE command. + /// + public static byte[] FormatMode(string account, GatewayInterestMode mode) + { + var modeStr = mode == GatewayInterestMode.InterestOnly ? "I" : "O"; + return Encoding.UTF8.GetBytes($"GMODE {account} {modeStr}\r\n"); + } + + /// + /// Parses a gateway command type from the first bytes of a line. + /// Returns null if the command prefix is unrecognized. + /// Go reference: gateway.go — processGatewayMsg command dispatch. + /// + public static GatewayCommandType? ParseCommandType(ReadOnlySpan line) + { + if (line.StartsWith(InfoPrefix)) return GatewayCommandType.Info; + if (line.StartsWith(SubPrefix)) return GatewayCommandType.Sub; + if (line.StartsWith(UnsubPrefix)) return GatewayCommandType.Unsub; + if (line.StartsWith(ModePrefix)) return GatewayCommandType.Mode; + if (line.StartsWith(MsgPrefix)) return GatewayCommandType.Msg; + if (line.StartsWith(Ping)) return GatewayCommandType.Ping; + if (line.StartsWith(Pong)) return GatewayCommandType.Pong; + return null; + } +} diff --git a/tests/NATS.Server.Tests/Gateways/GatewayCommandTests.cs b/tests/NATS.Server.Tests/Gateways/GatewayCommandTests.cs new file mode 100644 index 0000000..1a03e73 --- /dev/null +++ b/tests/NATS.Server.Tests/Gateways/GatewayCommandTests.cs @@ -0,0 +1,99 @@ +using System.Text; +using NATS.Server.Gateways; +using Shouldly; + +namespace NATS.Server.Tests.Gateways; + +// Go reference: gateway.go:90-120 — gateway protocol constants and command formatting. + +public class GatewayCommandTests +{ + [Fact] + public void FormatSub_produces_correct_wire_format() + { + // Go reference: gateway.go — RS+ propagation, sendGatewaySubsToGateway + var bytes = GatewayCommands.FormatSub("$G", "orders.>"); + var line = Encoding.UTF8.GetString(bytes); + line.ShouldBe("GS+ $G orders.>\r\n"); + } + + [Fact] + public void FormatUnsub_produces_correct_wire_format() + { + // Go reference: gateway.go — RS- propagation, sendGatewayUnsubToGateway + var bytes = GatewayCommands.FormatUnsub("$G", "orders.>"); + var line = Encoding.UTF8.GetString(bytes); + line.ShouldBe("GS- $G orders.>\r\n"); + } + + [Fact] + public void FormatMode_optimistic_produces_O() + { + // Go reference: gateway.go — GMODE command, "O" = Optimistic mode + var bytes = GatewayCommands.FormatMode("$G", GatewayInterestMode.Optimistic); + var line = Encoding.UTF8.GetString(bytes); + line.ShouldBe("GMODE $G O\r\n"); + } + + [Fact] + public void FormatMode_interest_only_produces_I() + { + // Go reference: gateway.go — GMODE command, "I" = InterestOnly mode + var bytes = GatewayCommands.FormatMode("$G", GatewayInterestMode.InterestOnly); + var line = Encoding.UTF8.GetString(bytes); + line.ShouldBe("GMODE $G I\r\n"); + } + + [Fact] + public void ParseCommandType_identifies_sub() + { + // Go reference: gateway.go — processGatewayMsg dispatch on GS+ + var line = Encoding.UTF8.GetBytes("GS+ ACC foo.bar\r\n"); + var result = GatewayCommands.ParseCommandType(line); + result.ShouldBe(GatewayCommandType.Sub); + } + + [Fact] + public void ParseCommandType_identifies_unsub() + { + // Go reference: gateway.go — processGatewayMsg dispatch on GS- + var line = Encoding.UTF8.GetBytes("GS- ACC foo.bar\r\n"); + var result = GatewayCommands.ParseCommandType(line); + result.ShouldBe(GatewayCommandType.Unsub); + } + + [Fact] + public void ParseCommandType_identifies_ping() + { + // Go reference: gateway.go — keepalive GPING command + var result = GatewayCommands.ParseCommandType(GatewayCommands.Ping); + result.ShouldBe(GatewayCommandType.Ping); + } + + [Fact] + public void ParseCommandType_identifies_pong() + { + // Go reference: gateway.go — keepalive GPONG response + var result = GatewayCommands.ParseCommandType(GatewayCommands.Pong); + result.ShouldBe(GatewayCommandType.Pong); + } + + [Fact] + public void ParseCommandType_returns_null_for_unknown() + { + // Unrecognized commands should return null for graceful handling + var line = Encoding.UTF8.GetBytes("UNKNOWN something\r\n"); + var result = GatewayCommands.ParseCommandType(line); + result.ShouldBeNull(); + } + + [Fact] + public void Wire_format_constants_end_correctly() + { + // Go reference: gateway.go — all gateway commands use CRLF line endings + var crlf = new byte[] { (byte)'\r', (byte)'\n' }; + GatewayCommands.Ping.TakeLast(2).ShouldBe(crlf); + GatewayCommands.Pong.TakeLast(2).ShouldBe(crlf); + GatewayCommands.Crlf.ShouldBe(crlf); + } +}