diff --git a/WireMock.Net Solution.sln b/WireMock.Net Solution.sln index 1654e8cd..56b67d24 100644 --- a/WireMock.Net Solution.sln +++ b/WireMock.Net Solution.sln @@ -154,6 +154,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WireMock.Net.Console.NET8.W EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WireMock.Net.WebApplication.IIS", "examples\WireMock.Net.WebApplication.IIS\WireMock.Net.WebApplication.IIS.csproj", "{5E6E9FA7-9135-7B82-2CCD-8CA87AC8043C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WireMock.Net.WebSocketExamples", "examples\WireMock.Net.WebSocketExamples\WireMock.Net.WebSocketExamples.csproj", "{2CE8E3A6-59CC-FE9C-9399-AD54E1FA862B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -788,6 +790,18 @@ Global {9957038D-F9C3-CA5D-E8AE-BE188E512635}.Release|x64.Build.0 = Release|Any CPU {9957038D-F9C3-CA5D-E8AE-BE188E512635}.Release|x86.ActiveCfg = Release|Any CPU {9957038D-F9C3-CA5D-E8AE-BE188E512635}.Release|x86.Build.0 = Release|Any CPU + {4005E20C-D42B-138A-79BE-B3F5420C563F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4005E20C-D42B-138A-79BE-B3F5420C563F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4005E20C-D42B-138A-79BE-B3F5420C563F}.Debug|x64.ActiveCfg = Debug|Any CPU + {4005E20C-D42B-138A-79BE-B3F5420C563F}.Debug|x64.Build.0 = Debug|Any CPU + {4005E20C-D42B-138A-79BE-B3F5420C563F}.Debug|x86.ActiveCfg = Debug|Any CPU + {4005E20C-D42B-138A-79BE-B3F5420C563F}.Debug|x86.Build.0 = Debug|Any CPU + {4005E20C-D42B-138A-79BE-B3F5420C563F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4005E20C-D42B-138A-79BE-B3F5420C563F}.Release|Any CPU.Build.0 = Release|Any CPU + {4005E20C-D42B-138A-79BE-B3F5420C563F}.Release|x64.ActiveCfg = Release|Any CPU + {4005E20C-D42B-138A-79BE-B3F5420C563F}.Release|x64.Build.0 = Release|Any CPU + {4005E20C-D42B-138A-79BE-B3F5420C563F}.Release|x86.ActiveCfg = Release|Any CPU + {4005E20C-D42B-138A-79BE-B3F5420C563F}.Release|x86.Build.0 = Release|Any CPU {2D86546D-8A24-0A55-C962-2071BD299E05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2D86546D-8A24-0A55-C962-2071BD299E05}.Debug|Any CPU.Build.0 = Debug|Any CPU {2D86546D-8A24-0A55-C962-2071BD299E05}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -812,18 +826,18 @@ Global {5E6E9FA7-9135-7B82-2CCD-8CA87AC8043C}.Release|x64.Build.0 = Release|Any CPU {5E6E9FA7-9135-7B82-2CCD-8CA87AC8043C}.Release|x86.ActiveCfg = Release|Any CPU {5E6E9FA7-9135-7B82-2CCD-8CA87AC8043C}.Release|x86.Build.0 = Release|Any CPU - {4005E20C-D42B-138A-79BE-B3F5420C563F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4005E20C-D42B-138A-79BE-B3F5420C563F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4005E20C-D42B-138A-79BE-B3F5420C563F}.Debug|x64.ActiveCfg = Debug|Any CPU - {4005E20C-D42B-138A-79BE-B3F5420C563F}.Debug|x64.Build.0 = Debug|Any CPU - {4005E20C-D42B-138A-79BE-B3F5420C563F}.Debug|x86.ActiveCfg = Debug|Any CPU - {4005E20C-D42B-138A-79BE-B3F5420C563F}.Debug|x86.Build.0 = Debug|Any CPU - {4005E20C-D42B-138A-79BE-B3F5420C563F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4005E20C-D42B-138A-79BE-B3F5420C563F}.Release|Any CPU.Build.0 = Release|Any CPU - {4005E20C-D42B-138A-79BE-B3F5420C563F}.Release|x64.ActiveCfg = Release|Any CPU - {4005E20C-D42B-138A-79BE-B3F5420C563F}.Release|x64.Build.0 = Release|Any CPU - {4005E20C-D42B-138A-79BE-B3F5420C563F}.Release|x86.ActiveCfg = Release|Any CPU - {4005E20C-D42B-138A-79BE-B3F5420C563F}.Release|x86.Build.0 = Release|Any CPU + {2CE8E3A6-59CC-FE9C-9399-AD54E1FA862B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2CE8E3A6-59CC-FE9C-9399-AD54E1FA862B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2CE8E3A6-59CC-FE9C-9399-AD54E1FA862B}.Debug|x64.ActiveCfg = Debug|Any CPU + {2CE8E3A6-59CC-FE9C-9399-AD54E1FA862B}.Debug|x64.Build.0 = Debug|Any CPU + {2CE8E3A6-59CC-FE9C-9399-AD54E1FA862B}.Debug|x86.ActiveCfg = Debug|Any CPU + {2CE8E3A6-59CC-FE9C-9399-AD54E1FA862B}.Debug|x86.Build.0 = Debug|Any CPU + {2CE8E3A6-59CC-FE9C-9399-AD54E1FA862B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2CE8E3A6-59CC-FE9C-9399-AD54E1FA862B}.Release|Any CPU.Build.0 = Release|Any CPU + {2CE8E3A6-59CC-FE9C-9399-AD54E1FA862B}.Release|x64.ActiveCfg = Release|Any CPU + {2CE8E3A6-59CC-FE9C-9399-AD54E1FA862B}.Release|x64.Build.0 = Release|Any CPU + {2CE8E3A6-59CC-FE9C-9399-AD54E1FA862B}.Release|x86.ActiveCfg = Release|Any CPU + {2CE8E3A6-59CC-FE9C-9399-AD54E1FA862B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -883,9 +897,10 @@ Global {2DBBD70D-8051-441F-92BB-FF9B8B4B4982} = {8F890C6F-9ACC-438D-928A-AD61CDA862F2} {C8F4E6D2-9A3B-4F1C-8D5E-7A2B3C4D5E6F} = {8F890C6F-9ACC-438D-928A-AD61CDA862F2} {9957038D-F9C3-CA5D-E8AE-BE188E512635} = {985E0ADB-D4B4-473A-AA40-567E279B7946} + {4005E20C-D42B-138A-79BE-B3F5420C563F} = {985E0ADB-D4B4-473A-AA40-567E279B7946} {2D86546D-8A24-0A55-C962-2071BD299E05} = {985E0ADB-D4B4-473A-AA40-567E279B7946} {5E6E9FA7-9135-7B82-2CCD-8CA87AC8043C} = {985E0ADB-D4B4-473A-AA40-567E279B7946} - {4005E20C-D42B-138A-79BE-B3F5420C563F} = {985E0ADB-D4B4-473A-AA40-567E279B7946} + {2CE8E3A6-59CC-FE9C-9399-AD54E1FA862B} = {985E0ADB-D4B4-473A-AA40-567E279B7946} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DC539027-9852-430C-B19F-FD035D018458} diff --git a/examples/WireMock.Net.WebSocketExamples/Program.cs b/examples/WireMock.Net.WebSocketExamples/Program.cs new file mode 100644 index 00000000..4cf92135 --- /dev/null +++ b/examples/WireMock.Net.WebSocketExamples/Program.cs @@ -0,0 +1,725 @@ +// Copyright © WireMock.Net + +using System; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using WireMock.Logging; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using WireMock.Settings; + +namespace WireMock.Net.WebSocketExamples; + +public static class Program +{ + public static async Task Main(string[] args) + { + Console.WriteLine("WireMock.Net WebSocket Examples"); + Console.WriteLine("================================\n"); + + Console.WriteLine("Choose an example to run:"); + Console.WriteLine("1. Echo Server"); + Console.WriteLine("2. Custom Message Handler"); + Console.WriteLine("3. Broadcast Server"); + Console.WriteLine("4. Scenario/State Machine"); + Console.WriteLine("5. WebSocket Proxy"); + Console.WriteLine("6. Multiple WebSocket Endpoints"); + Console.WriteLine("7. All Examples (runs all endpoints)"); + Console.WriteLine("0. Exit\n"); + + Console.Write("Enter choice: "); + var choice = Console.ReadLine(); + + switch (choice) + { + case "1": + await RunEchoServerExample(); + break; + case "2": + await RunCustomMessageHandlerExample(); + break; + case "3": + await RunBroadcastExample(); + break; + case "4": + await RunScenarioExample(); + break; + case "5": + await RunProxyExample(); + break; + case "6": + await RunMultipleEndpointsExample(); + break; + case "7": + await RunAllExamples(); + break; + case "0": + return; + default: + Console.WriteLine("Invalid choice"); + break; + } + } + + /// + /// Example 1: Simple Echo Server + /// Echoes back all messages received from the client + /// + private static async Task RunEchoServerExample() + { + Console.WriteLine("\n=== Echo Server Example ==="); + Console.WriteLine("Starting WebSocket echo server...\n"); + + var server = WireMockServer.Start(new WireMockServerSettings + { + Port = 9091, + Logger = new WireMockConsoleLogger() + }); + + server + .Given(Request.Create() + .WithPath("/ws/echo") + .WithWebSocketUpgrade() + ) + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithEcho() + ) + ); + + Console.WriteLine($"Echo server listening at: {server.Urls[0]}/ws/echo"); + Console.WriteLine("\nTest with a WebSocket client:"); + Console.WriteLine(" wscat -c ws://localhost:9091/ws/echo"); + Console.WriteLine("\nPress any key to test or CTRL+C to exit..."); + Console.ReadKey(); + + // Test the echo server + await TestWebSocketEcho(server.Urls[0]); + + Console.WriteLine("\nPress any key to stop server..."); + Console.ReadKey(); + server.Stop(); + } + + /// + /// Example 2: Custom Message Handler + /// Processes messages and sends custom responses + /// + private static async Task RunCustomMessageHandlerExample() + { + Console.WriteLine("\n=== Custom Message Handler Example ==="); + Console.WriteLine("Starting WebSocket server with custom message handler...\n"); + + var server = WireMockServer.Start(new WireMockServerSettings + { + Port = 9091, + Logger = new WireMockConsoleLogger() + }); + + server + .Given(Request.Create() + .WithPath("/ws/chat") + .WithWebSocketUpgrade() + ) + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithMessageHandler(async (message, context) => + { + if (message.MessageType == WebSocketMessageType.Text) + { + var text = message.Text ?? string.Empty; + + // Handle different commands + if (text.StartsWith("/help")) + { + await context.SendTextAsync("Available commands: /help, /time, /echo , /upper , /reverse "); + } + else if (text.StartsWith("/time")) + { + await context.SendTextAsync($"Server time: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); + } + else if (text.StartsWith("/echo ")) + { + await context.SendTextAsync(text.Substring(6)); + } + else if (text.StartsWith("/upper ")) + { + await context.SendTextAsync(text.Substring(7).ToUpper()); + } + else if (text.StartsWith("/reverse ")) + { + var toReverse = text.Substring(9); + var reversed = new string(toReverse.Reverse().ToArray()); + await context.SendTextAsync(reversed); + } + else if (text == "/quit") + { + await context.SendTextAsync("Goodbye!"); + await context.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client requested disconnect"); + } + else + { + await context.SendTextAsync($"Unknown command: {text}. Type /help for available commands."); + } + } + }) + ) + ); + + Console.WriteLine($"Chat server listening at: {server.Urls[0]}/ws/chat"); + Console.WriteLine("\nTest with:"); + Console.WriteLine(" wscat -c ws://localhost:9091/ws/chat"); + Console.WriteLine("\nThen try commands: /help, /time, /echo hello, /upper hello, /reverse hello"); + Console.WriteLine("\nPress any key to test or CTRL+C to exit..."); + Console.ReadKey(); + + await TestWebSocketChat(server.Urls[0]); + + Console.WriteLine("\nPress any key to stop server..."); + Console.ReadKey(); + server.Stop(); + } + + /// + /// Example 3: Broadcast Server + /// Broadcasts messages to all connected clients + /// + private static async Task RunBroadcastExample() + { + Console.WriteLine("\n=== Broadcast Server Example ==="); + Console.WriteLine("Starting WebSocket broadcast server...\n"); + + var server = WireMockServer.Start(new WireMockServerSettings + { + Port = 9091, + Logger = new WireMockConsoleLogger() + }); + + var broadcastMappingGuid = Guid.NewGuid(); + + server + .Given(Request.Create() + .WithPath("/ws/broadcast") + .WithWebSocketUpgrade() + ) + .WithGuid(broadcastMappingGuid) + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithBroadcast() + .WithMessageHandler(async (message, context) => + { + if (message.MessageType == WebSocketMessageType.Text) + { + var text = message.Text ?? string.Empty; + var timestamp = DateTime.UtcNow.ToString("HH:mm:ss"); + var broadcastMessage = $"[{timestamp}] Broadcast: {text}"; + + // Broadcast to all connected clients + await context.BroadcastTextAsync(broadcastMessage); + + Console.WriteLine($"Broadcasted to {server.GetWebSocketConnections(broadcastMappingGuid).Count} clients: {text}"); + } + }) + ) + ); + + Console.WriteLine($"Broadcast server listening at: {server.Urls[0]}/ws/broadcast"); + Console.WriteLine("\nConnect multiple clients:"); + Console.WriteLine(" wscat -c ws://localhost:9091/ws/broadcast"); + Console.WriteLine("\nMessages sent from any client will be broadcast to all clients"); + Console.WriteLine("\nPress any key to stop server..."); + Console.ReadKey(); + server.Stop(); + } + + /// + /// Example 4: Scenario/State Machine + /// Demonstrates state transitions during WebSocket session + /// + private static async Task RunScenarioExample() + { + Console.WriteLine("\n=== Scenario/State Machine Example ==="); + Console.WriteLine("Starting WebSocket server with scenario support...\n"); + + var server = WireMockServer.Start(new WireMockServerSettings + { + Port = 9091, + Logger = new WireMockConsoleLogger() + }); + + // Initial state: Waiting for players + server + .Given(Request.Create() + .WithPath("/ws/game") + .WithWebSocketUpgrade() + ) + .InScenario("GameSession") + .WillSetStateTo("Lobby") + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithMessageHandler(async (msg, ctx) => + { + await ctx.SendTextAsync("Welcome to the game lobby! Type 'ready' to start or 'quit' to leave."); + }) + ) + ); + + // Lobby state: Waiting for ready + server + .Given(Request.Create() + .WithPath("/ws/game") + .WithWebSocketUpgrade() + ) + .InScenario("GameSession") + .WhenStateIs("Lobby") + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithMessageHandler(async (msg, ctx) => + { + var text = msg.Text?.ToLower() ?? string.Empty; + + if (text == "ready") + { + ctx.SetScenarioState("Playing"); + await ctx.SendTextAsync("Game started! Type 'attack' to attack, 'defend' to defend, or 'quit' to exit."); + } + else if (text == "quit") + { + await ctx.SendTextAsync("You left the lobby. Goodbye!"); + await ctx.CloseAsync(WebSocketCloseStatus.NormalClosure, "Player quit"); + } + else + { + await ctx.SendTextAsync("In lobby. Type 'ready' to start or 'quit' to leave."); + } + }) + ) + ); + + // Playing state: Game is active + server + .Given(Request.Create() + .WithPath("/ws/game") + .WithWebSocketUpgrade() + ) + .InScenario("GameSession") + .WhenStateIs("Playing") + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithMessageHandler(async (msg, ctx) => + { + var text = msg.Text?.ToLower() ?? string.Empty; + + if (text == "attack") + { + await ctx.SendTextAsync("You attacked! Critical hit! 💥"); + } + else if (text == "defend") + { + await ctx.SendTextAsync("You defended! Shield up! 🛡️"); + } + else if (text == "quit") + { + ctx.SetScenarioState("GameOver"); + await ctx.SendTextAsync("Game over! Thanks for playing."); + await ctx.CloseAsync(WebSocketCloseStatus.NormalClosure, "Game ended"); + } + else + { + await ctx.SendTextAsync("Unknown action. Type 'attack', 'defend', or 'quit'."); + } + }) + ) + ); + + Console.WriteLine($"Game server listening at: {server.Urls[0]}/ws/game"); + Console.WriteLine("\nConnect and follow the game flow:"); + Console.WriteLine(" wscat -c ws://localhost:9091/ws/game"); + Console.WriteLine("\nGame flow: Lobby -> Type 'ready' -> Playing -> Type 'attack'/'defend' -> Type 'quit'"); + Console.WriteLine("\nPress any key to stop server..."); + Console.ReadKey(); + server.Stop(); + } + + /// + /// Example 5: WebSocket Proxy + /// Proxies WebSocket connections to another server + /// + private static async Task RunProxyExample() + { + Console.WriteLine("\n=== WebSocket Proxy Example ==="); + Console.WriteLine("Starting WebSocket proxy server...\n"); + + var server = WireMockServer.Start(new WireMockServerSettings + { + Port = 9091, + Logger = new WireMockConsoleLogger() + }); + + server + .Given(Request.Create() + .WithPath("/ws/proxy") + .WithWebSocketUpgrade() + ) + .RespondWith(Response.Create() + .WithWebSocketProxy("ws://echo.websocket.org") + ); + + Console.WriteLine($"Proxy server listening at: {server.Urls[0]}/ws/proxy"); + Console.WriteLine("Proxying to: ws://echo.websocket.org"); + Console.WriteLine("\nTest with:"); + Console.WriteLine(" wscat -c ws://localhost:9091/ws/proxy"); + Console.WriteLine("\nPress any key to stop server..."); + Console.ReadKey(); + server.Stop(); + } + + /// + /// Example 6: Multiple WebSocket Endpoints + /// Demonstrates running multiple WebSocket endpoints simultaneously + /// + private static async Task RunMultipleEndpointsExample() + { + Console.WriteLine("\n=== Multiple WebSocket Endpoints Example ==="); + Console.WriteLine("Starting server with multiple WebSocket endpoints...\n"); + + var server = WireMockServer.Start(new WireMockServerSettings + { + Port = 9091, + Logger = new WireMockConsoleLogger(), + WebSocketSettings = new WebSocketSettings + { + MaxConnections = 100, + KeepAliveIntervalSeconds = 30 + } + }); + + // Endpoint 1: Echo + server + .Given(Request.Create() + .WithPath("/ws/echo") + .WithWebSocketUpgrade() + ) + .RespondWith(Response.Create() + .WithWebSocket(ws => ws.WithEcho()) + ); + + // Endpoint 2: Time service + server + .Given(Request.Create() + .WithPath("/ws/time") + .WithWebSocketUpgrade() + ) + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithMessageHandler(async (msg, ctx) => + { + await ctx.SendTextAsync($"Server time: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); + }) + ) + ); + + // Endpoint 3: JSON service + server + .Given(Request.Create() + .WithPath("/ws/json") + .WithWebSocketUpgrade() + ) + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithMessageHandler(async (msg, ctx) => + { + var response = new + { + timestamp = DateTime.UtcNow, + message = msg.Text, + length = msg.Text?.Length ?? 0, + type = msg.MessageType.ToString() + }; + await ctx.SendJsonAsync(response); + }) + ) + ); + + // Endpoint 4: Protocol-specific + server + .Given(Request.Create() + .WithPath("/ws/protocol") + .WithWebSocketUpgrade("chat", "superchat") + ) + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithAcceptProtocol("chat") + .WithMessageHandler(async (msg, ctx) => + { + await ctx.SendTextAsync($"Using protocol: chat. Message: {msg.Text}"); + }) + ) + ); + + Console.WriteLine("Available WebSocket endpoints:"); + Console.WriteLine($" 1. Echo: {server.Urls[0]}/ws/echo"); + Console.WriteLine($" 2. Time: {server.Urls[0]}/ws/time"); + Console.WriteLine($" 3. JSON: {server.Urls[0]}/ws/json"); + Console.WriteLine($" 4. Protocol: {server.Urls[0]}/ws/protocol"); + Console.WriteLine("\nTest with wscat:"); + Console.WriteLine(" wscat -c ws://localhost:9091/ws/echo"); + Console.WriteLine(" wscat -c ws://localhost:9091/ws/time"); + Console.WriteLine(" wscat -c ws://localhost:9091/ws/json"); + Console.WriteLine(" wscat -c ws://localhost:9091/ws/protocol -s chat"); + Console.WriteLine("\nPress any key to stop server..."); + Console.ReadKey(); + server.Stop(); + } + + /// + /// Example 7: Run All Examples + /// Starts a server with all example endpoints + /// + private static async Task RunAllExamples() + { + Console.WriteLine("\n=== All Examples Running ==="); + Console.WriteLine("Starting server with all WebSocket endpoints...\n"); + + var server = WireMockServer.Start(new WireMockServerSettings + { + Port = 9091, + Logger = new WireMockConsoleLogger(), + WebSocketSettings = new WebSocketSettings + { + MaxConnections = 200 + } + }); + + SetupAllEndpoints(server); + + Console.WriteLine("All WebSocket endpoints are running:"); + Console.WriteLine($" Echo: {server.Urls[0]}/ws/echo"); + Console.WriteLine($" Chat: {server.Urls[0]}/ws/chat"); + Console.WriteLine($" Broadcast: {server.Urls[0]}/ws/broadcast"); + Console.WriteLine($" Game: {server.Urls[0]}/ws/game"); + Console.WriteLine($" Time: {server.Urls[0]}/ws/time"); + Console.WriteLine($" JSON: {server.Urls[0]}/ws/json"); + Console.WriteLine("\nServer statistics:"); + Console.WriteLine($" Total mappings: {server.Mappings.Count}"); + + Console.WriteLine("\nPress any key to view connection stats or CTRL+C to exit..."); + + while (true) + { + Console.ReadKey(true); + var connections = server.GetWebSocketConnections(); + Console.WriteLine($"\nActive WebSocket connections: {connections.Count}"); + foreach (var conn in connections) + { + Console.WriteLine($" - {conn.ConnectionId}: {conn.RequestMessage.Path} (State: {conn.WebSocket.State})"); + } + Console.WriteLine("\nPress any key to refresh or CTRL+C to exit..."); + } + } + + private static void SetupAllEndpoints(WireMockServer server) + { + // Echo endpoint + server + .Given(Request.Create() + .WithPath("/ws/echo") + .WithWebSocketUpgrade() + ) + .RespondWith(Response.Create() + .WithWebSocket(ws => ws.WithEcho()) + ); + + // Chat endpoint + server + .Given(Request.Create() + .WithPath("/ws/chat") + .WithWebSocketUpgrade() + ) + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithMessageHandler(async (message, context) => + { + if (message.MessageType == WebSocketMessageType.Text) + { + await context.SendTextAsync($"Echo: {message.Text}"); + } + }) + ) + ); + + // Broadcast endpoint + var broadcastGuid = Guid.NewGuid(); + server + .Given(Request.Create() + .WithPath("/ws/broadcast") + .WithWebSocketUpgrade() + ) + .WithGuid(broadcastGuid) + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithBroadcast() + .WithMessageHandler(async (message, context) => + { + if (message.MessageType == WebSocketMessageType.Text) + { + await context.BroadcastTextAsync($"[Broadcast] {message.Text}"); + } + }) + ) + ); + + // Game scenario endpoint + SetupGameScenario(server); + + // Time endpoint + server + .Given(Request.Create() + .WithPath("/ws/time") + .WithWebSocketUpgrade() + ) + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithMessageHandler(async (msg, ctx) => + { + await ctx.SendTextAsync($"Server time: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); + }) + ) + ); + + // JSON endpoint + server + .Given(Request.Create() + .WithPath("/ws/json") + .WithWebSocketUpgrade() + ) + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithMessageHandler(async (msg, ctx) => + { + var response = new + { + timestamp = DateTime.UtcNow, + message = msg.Text, + connectionId = ctx.ConnectionId + }; + await ctx.SendJsonAsync(response); + }) + ) + ); + } + + private static void SetupGameScenario(WireMockServer server) + { + server + .Given(Request.Create() + .WithPath("/ws/game") + .WithWebSocketUpgrade() + ) + .InScenario("GameSession") + .WillSetStateTo("Lobby") + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithMessageHandler(async (msg, ctx) => + { + await ctx.SendTextAsync("Welcome! Type 'ready' to start."); + }) + ) + ); + + server + .Given(Request.Create() + .WithPath("/ws/game") + .WithWebSocketUpgrade() + ) + .InScenario("GameSession") + .WhenStateIs("Lobby") + .RespondWith(Response.Create() + .WithWebSocket(ws => ws + .WithMessageHandler(async (msg, ctx) => + { + if (msg.Text?.ToLower() == "ready") + { + ctx.SetScenarioState("Playing"); + await ctx.SendTextAsync("Game started!"); + } + }) + ) + ); + } + + // Helper methods for testing + private static async Task TestWebSocketEcho(string baseUrl) + { + try + { + using var client = new ClientWebSocket(); + var uri = new Uri($"{baseUrl.Replace("http://", "ws://")}/ws/echo"); + + Console.WriteLine($"\nConnecting to {uri}..."); + await client.ConnectAsync(uri, CancellationToken.None); + Console.WriteLine("Connected!"); + + var testMessages = new[] { "Hello", "World", "WebSocket", "Test" }; + + foreach (var testMessage in testMessages) + { + Console.WriteLine($"\nSending: {testMessage}"); + var bytes = Encoding.UTF8.GetBytes(testMessage); + await client.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, CancellationToken.None); + + var buffer = new byte[1024]; + var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + var received = Encoding.UTF8.GetString(buffer, 0, result.Count); + Console.WriteLine($"Received: {received}"); + } + + await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "Test complete", CancellationToken.None); + Console.WriteLine("\nTest completed successfully!"); + } + catch (Exception ex) + { + Console.WriteLine($"\nTest failed: {ex.Message}"); + } + } + + private static async Task TestWebSocketChat(string baseUrl) + { + try + { + using var client = new ClientWebSocket(); + var uri = new Uri($"{baseUrl.Replace("http://", "ws://")}/ws/chat"); + + Console.WriteLine($"\nConnecting to {uri}..."); + await client.ConnectAsync(uri, CancellationToken.None); + Console.WriteLine("Connected!"); + + var commands = new[] { "/help", "/time", "/echo Hello", "/upper test", "/reverse hello" }; + + foreach (var command in commands) + { + Console.WriteLine($"\nSending: {command}"); + var bytes = Encoding.UTF8.GetBytes(command); + await client.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, CancellationToken.None); + + var buffer = new byte[1024]; + var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + var received = Encoding.UTF8.GetString(buffer, 0, result.Count); + Console.WriteLine($"Received: {received}"); + + await Task.Delay(500); + } + + await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "Test complete", CancellationToken.None); + Console.WriteLine("\nTest completed successfully!"); + } + catch (Exception ex) + { + Console.WriteLine($"\nTest failed: {ex.Message}"); + } + } +} diff --git a/examples/WireMock.Net.WebSocketExamples/README.md b/examples/WireMock.Net.WebSocketExamples/README.md new file mode 100644 index 00000000..3411c68f --- /dev/null +++ b/examples/WireMock.Net.WebSocketExamples/README.md @@ -0,0 +1,156 @@ +# WireMock.Net WebSocket Examples + +This project demonstrates all the WebSocket capabilities of WireMock.Net. + +## Prerequisites + +- .NET 8.0 SDK +- Optional: `wscat` for manual testing (`npm install -g wscat`) + +## Running the Examples + +```bash +cd examples/WireMock.Net.WebSocketExamples +dotnet run +``` + +## Available Examples + +### 1. Echo Server +Simple WebSocket echo server that returns all messages back to the client. + +**Test with:** +```bash +wscat -c ws://localhost:9091/ws/echo +``` + +### 2. Custom Message Handler +Chat server with commands: `/help`, `/time`, `/echo`, `/upper`, `/reverse`, `/quit` + +**Test with:** +```bash +wscat -c ws://localhost:9091/ws/chat +> /help +> /time +> /echo Hello World +> /upper test +> /reverse hello +``` + +### 3. Broadcast Server +Messages sent by any client are broadcast to all connected clients. + +**Test with multiple terminals:** +```bash +# Terminal 1 +wscat -c ws://localhost:9091/ws/broadcast + +# Terminal 2 +wscat -c ws://localhost:9091/ws/broadcast + +# Terminal 3 +wscat -c ws://localhost:9091/ws/broadcast +``` + +Type messages in any terminal and see them appear in all terminals. + +### 4. Scenario/State Machine +Game server with state transitions: Lobby -> Playing -> GameOver + +**Test with:** +```bash +wscat -c ws://localhost:9091/ws/game +> ready +> attack +> defend +> quit +``` + +### 5. WebSocket Proxy +Proxies WebSocket connections to echo.websocket.org + +**Test with:** +```bash +wscat -c ws://localhost:9091/ws/proxy +``` + +### 6. Multiple Endpoints +Runs multiple WebSocket endpoints simultaneously: +- `/ws/echo` - Echo server +- `/ws/time` - Returns server time +- `/ws/json` - Returns JSON responses +- `/ws/protocol` - Protocol-specific endpoint + +### 7. All Examples +Runs all endpoints at once with connection statistics. + +## Features Demonstrated + +- ✅ **Echo Server** - Simple message echo +- ✅ **Custom Handlers** - Complex message processing +- ✅ **Broadcast** - Multi-client communication +- ✅ **Scenarios** - State machine patterns +- ✅ **Proxy** - Forwarding to real WebSocket servers +- ✅ **Protocol Negotiation** - Sec-WebSocket-Protocol support +- ✅ **JSON Messaging** - Structured data exchange +- ✅ **Connection Management** - Track and manage connections +- ✅ **Configuration** - Custom WebSocket settings + +## Testing with wscat + +Install wscat globally: +```bash +npm install -g wscat +``` + +Basic usage: +```bash +# Connect to endpoint +wscat -c ws://localhost:9091/ws/echo + +# Connect with protocol +wscat -c ws://localhost:9091/ws/protocol -s chat + +# Connect with headers +wscat -c ws://localhost:9091/ws/echo -H "X-Custom-Header: value" +``` + +## Testing with C# Client + +The examples include built-in C# WebSocket clients for automated testing. +Select options 1 or 2 and press any key to run the automated tests. + +## Configuration + +WebSocket settings can be configured: + +```csharp +var server = WireMockServer.Start(new WireMockServerSettings +{ + Port = 9091, + WebSocketSettings = new WebSocketSettings + { + MaxConnections = 100, + ReceiveBufferSize = 8192, + MaxMessageSize = 1048576, + KeepAliveInterval = TimeSpan.FromSeconds(30), + CloseTimeout = TimeSpan.FromMinutes(10), + EnableCompression = true + } +}); +``` + +## Monitoring + +When running "All Examples" (option 7), press any key to view: +- Active connection count +- Connection IDs +- Request paths +- WebSocket states + +## Notes + +- All examples run on port 9091 by default +- Press CTRL+C to stop the server +- Multiple clients can connect simultaneously +- Connection states are tracked and can be queried diff --git a/examples/WireMock.Net.WebSocketExamples/WireMock.Net.WebSocketExamples.csproj b/examples/WireMock.Net.WebSocketExamples/WireMock.Net.WebSocketExamples.csproj new file mode 100644 index 00000000..c62051d0 --- /dev/null +++ b/examples/WireMock.Net.WebSocketExamples/WireMock.Net.WebSocketExamples.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0 + WireMock.Net.WebSocketExamples + WireMock.Net.WebSocketExamples + + + + + + + + + + + diff --git a/src/WireMock.Net.Abstractions/Admin/Settings/SettingsModel.cs b/src/WireMock.Net.Abstractions/Admin/Settings/SettingsModel.cs index 976dfcd9..90351c58 100644 --- a/src/WireMock.Net.Abstractions/Admin/Settings/SettingsModel.cs +++ b/src/WireMock.Net.Abstractions/Admin/Settings/SettingsModel.cs @@ -131,4 +131,9 @@ public class SettingsModel /// Whether to accept any client certificate /// public bool AcceptAnyClientCertificate { get; set; } + + /// + /// Gets or sets the WebSocket settings. + /// + public WebSocketSettingsModel? WebSocketSettings { get; set; } } \ No newline at end of file diff --git a/src/WireMock.Net.Abstractions/Admin/Settings/WebSocketSettingsModel.cs b/src/WireMock.Net.Abstractions/Admin/Settings/WebSocketSettingsModel.cs new file mode 100644 index 00000000..624d7918 --- /dev/null +++ b/src/WireMock.Net.Abstractions/Admin/Settings/WebSocketSettingsModel.cs @@ -0,0 +1,40 @@ +// Copyright © WireMock.Net + +namespace WireMock.Admin.Settings; + +/// +/// WebSocket Settings Model +/// +[FluentBuilder.AutoGenerateBuilder] +public class WebSocketSettingsModel +{ + /// + /// Maximum number of concurrent WebSocket connections (default: 100) + /// + public int MaxConnections { get; set; } = 100; + + /// + /// Default receive buffer size in bytes (default: 4096) + /// + public int ReceiveBufferSize { get; set; } = 4096; + + /// + /// Default keep-alive interval in seconds (default: 30) + /// + public int KeepAliveIntervalSeconds { get; set; } = 30; + + /// + /// Maximum message size in bytes (default: 1048576 - 1 MB) + /// + public int MaxMessageSize { get; set; } = 1048576; + + /// + /// Enable WebSocket compression (default: true) + /// + public bool EnableCompression { get; set; } = true; + + /// + /// Default close timeout in minutes (default: 10) + /// + public int CloseTimeoutMinutes { get; set; } = 10; +} diff --git a/src/WireMock.Net.Abstractions/Constants/WebSocketConstants.cs b/src/WireMock.Net.Abstractions/Constants/WebSocketConstants.cs new file mode 100644 index 00000000..1cdefdd4 --- /dev/null +++ b/src/WireMock.Net.Abstractions/Constants/WebSocketConstants.cs @@ -0,0 +1,39 @@ +// Copyright © WireMock.Net + +namespace WireMock.Constants; + +/// +/// WebSocket constants +/// +public static class WebSocketConstants +{ + /// + /// Default receive buffer size for WebSocket messages (4 KB) + /// + public const int DefaultReceiveBufferSize = 4096; + + /// + /// Default keep-alive interval in seconds + /// + public const int DefaultKeepAliveIntervalSeconds = 30; + + /// + /// Default close timeout in minutes + /// + public const int DefaultCloseTimeoutMinutes = 10; + + /// + /// Minimum buffer size for WebSocket operations (1 KB) + /// + public const int MinimumBufferSize = 1024; + + /// + /// Default maximum message size (1 MB) + /// + public const int DefaultMaxMessageSize = 1024 * 1024; + + /// + /// Proxy forward buffer size (4 KB) + /// + public const int ProxyForwardBufferSize = 4096; +} diff --git a/src/WireMock.Net.Abstractions/WireMock.Net.Abstractions.csproj b/src/WireMock.Net.Abstractions/WireMock.Net.Abstractions.csproj index 1e1f1682..019e8a6b 100644 --- a/src/WireMock.Net.Abstractions/WireMock.Net.Abstractions.csproj +++ b/src/WireMock.Net.Abstractions/WireMock.Net.Abstractions.csproj @@ -36,7 +36,7 @@ - + diff --git a/src/WireMock.Net.Minimal/Owin/AspNetCoreSelfHost.cs b/src/WireMock.Net.Minimal/Owin/AspNetCoreSelfHost.cs index 424c6e9d..bb696c2d 100644 --- a/src/WireMock.Net.Minimal/Owin/AspNetCoreSelfHost.cs +++ b/src/WireMock.Net.Minimal/Owin/AspNetCoreSelfHost.cs @@ -80,6 +80,14 @@ internal partial class AspNetCoreSelfHost : IOwinSelfHost #if NET8_0_OR_GREATER UseCors(appBuilder); + + var webSocketOptions = new WebSocketOptions(); + if (_wireMockMiddlewareOptions.WebSocketSettings?.KeepAliveIntervalSeconds != null) + { + webSocketOptions.KeepAliveInterval = TimeSpan.FromSeconds(_wireMockMiddlewareOptions.WebSocketSettings.KeepAliveIntervalSeconds); + } + + appBuilder.UseWebSockets(webSocketOptions); #endif _wireMockMiddlewareOptions.PreWireMockMiddlewareInit?.Invoke(appBuilder); diff --git a/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareOptions.cs b/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareOptions.cs index 14ce7e50..55dd5e65 100644 --- a/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareOptions.cs +++ b/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareOptions.cs @@ -11,6 +11,7 @@ using WireMock.Matchers; using WireMock.Settings; using WireMock.Types; using WireMock.Util; +using WireMock.WebSockets; using ClientCertificateMode = Microsoft.AspNetCore.Server.Kestrel.Https.ClientCertificateMode; namespace WireMock.Owin; @@ -82,11 +83,21 @@ internal interface IWireMockMiddlewareOptions QueryParameterMultipleValueSupport? QueryParameterMultipleValueSupport { get; set; } - public bool ProxyAll { get; set; } + bool ProxyAll { get; set; } /// /// Gets or sets the activity tracing options. /// When set, System.Diagnostics.Activity objects are created for request tracing. /// ActivityTracingOptions? ActivityTracingOptions { get; set; } + + /// + /// The WebSocket connection registries per mapping (used for broadcast). + /// + ConcurrentDictionary WebSocketRegistries { get; } + + /// + /// WebSocket settings. + /// + WebSocketSettings? WebSocketSettings { get; set; } } \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/Owin/Mappers/OwinResponseMapper.cs b/src/WireMock.Net.Minimal/Owin/Mappers/OwinResponseMapper.cs index a6c65f11..b0618eca 100644 --- a/src/WireMock.Net.Minimal/Owin/Mappers/OwinResponseMapper.cs +++ b/src/WireMock.Net.Minimal/Owin/Mappers/OwinResponseMapper.cs @@ -15,6 +15,7 @@ using RandomDataGenerator.Randomizers; using Stef.Validation; using WireMock.Http; using WireMock.ResponseBuilders; +using WireMock.ResponseProviders; using WireMock.Types; using WireMock.Util; @@ -58,7 +59,7 @@ namespace WireMock.Owin.Mappers /// public async Task MapAsync(IResponseMessage? responseMessage, HttpResponse response) { - if (responseMessage == null) + if (responseMessage == null || responseMessage is WebSocketHandledResponse) { return; } diff --git a/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs b/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs index 1cb84ce6..20c2f876 100644 --- a/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs +++ b/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs @@ -25,7 +25,6 @@ namespace WireMock.Owin; internal class WireMockMiddleware { private readonly object _lock = new(); - private static readonly Task CompletedTask = Task.FromResult(false); private readonly IWireMockMiddlewareOptions _options; private readonly IOwinRequestMapper _requestMapper; @@ -66,6 +65,9 @@ internal class WireMockMiddleware private async Task InvokeInternalAsync(HttpContext ctx) { + // Store options in HttpContext for providers to access (e.g., WebSocketResponseProvider) + ctx.Items[nameof(WireMockMiddlewareOptions)] = _options; + var request = await _requestMapper.MapAsync(ctx.Request, _options).ConfigureAwait(false); var logRequest = false; @@ -144,9 +146,7 @@ internal class WireMockMiddleware var (theResponse, theOptionalNewMapping) = await targetMapping.ProvideResponseAsync(ctx, request).ConfigureAwait(false); response = theResponse; - var responseBuilder = targetMapping.Provider as Response; - - if (!targetMapping.IsAdminInterface && theOptionalNewMapping != null) + if (targetMapping.Provider is Response responseBuilder && !targetMapping.IsAdminInterface && theOptionalNewMapping != null) { if (responseBuilder?.ProxyAndRecordSettings?.SaveMapping == true || targetMapping.Settings.ProxyAndRecordSettings?.SaveMapping == true) { @@ -227,8 +227,6 @@ internal class WireMockMiddleware await _responseMapper.MapAsync(notFoundResponse, ctx.Response).ConfigureAwait(false); } } - - await CompletedTask.ConfigureAwait(false); } private async Task SendToWebhooksAsync(IMapping mapping, IRequestMessage request, IResponseMessage response) diff --git a/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptions.cs b/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptions.cs index 002b5a1b..e07c85f8 100644 --- a/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptions.cs +++ b/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptions.cs @@ -8,10 +8,10 @@ using Microsoft.Extensions.DependencyInjection; using WireMock.Handlers; using WireMock.Logging; using WireMock.Matchers; -using WireMock.Owin.ActivityTracing; using WireMock.Settings; using WireMock.Types; using WireMock.Util; +using WireMock.WebSockets; using ClientCertificateMode = Microsoft.AspNetCore.Server.Kestrel.Https.ClientCertificateMode; namespace WireMock.Owin; @@ -40,7 +40,6 @@ internal class WireMockMiddlewareOptions : IWireMockMiddlewareOptions public Action? PostWireMockMiddlewareInit { get; set; } -//#if USE_ASPNETCORE public Action? AdditionalServiceRegistration { get; set; } public CorsPolicyOptions? CorsPolicyOptions { get; set; } @@ -49,7 +48,6 @@ internal class WireMockMiddlewareOptions : IWireMockMiddlewareOptions /// public bool AcceptAnyClientCertificate { get; set; } - //#endif /// public IFileSystemHandler? FileSystemHandler { get; set; } @@ -107,4 +105,9 @@ internal class WireMockMiddlewareOptions : IWireMockMiddlewareOptions /// public ActivityTracingOptions? ActivityTracingOptions { get; set; } + + /// + public ConcurrentDictionary WebSocketRegistries { get; } = new(); + + public WebSocketSettings? WebSocketSettings { get; set; } } \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/RequestBuilders/Request.WithWebSocket.cs b/src/WireMock.Net.Minimal/RequestBuilders/Request.WithWebSocket.cs new file mode 100644 index 00000000..5f47b2b7 --- /dev/null +++ b/src/WireMock.Net.Minimal/RequestBuilders/Request.WithWebSocket.cs @@ -0,0 +1,43 @@ +// Copyright © WireMock.Net + +using System.Linq; +using WireMock.Matchers; +using WireMock.Matchers.Request; + +namespace WireMock.RequestBuilders; + +public partial class Request +{ + /// + public IRequestBuilder WithWebSocketUpgrade(params string[] protocols) + { + _requestMatchers.Add(new RequestMessageHeaderMatcher( + MatchBehaviour.AcceptOnMatch, + MatchOperator.Or, + "Upgrade", + true, + new ExactMatcher(true, "websocket") + )); + + _requestMatchers.Add(new RequestMessageHeaderMatcher( + MatchBehaviour.AcceptOnMatch, + MatchOperator.Or, + "Connection", + true, + new WildcardMatcher("*Upgrade*", true) + )); + + if (protocols.Length > 0) + { + _requestMatchers.Add(new RequestMessageHeaderMatcher( + MatchBehaviour.AcceptOnMatch, + MatchOperator.Or, + "Sec-WebSocket-Protocol", + true, + protocols.Select(p => new ExactMatcher(true, p)).ToArray() + )); + } + + return this; + } +} \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/ResponseBuilders/Response.WithWebSocket.cs b/src/WireMock.Net.Minimal/ResponseBuilders/Response.WithWebSocket.cs new file mode 100644 index 00000000..93c14714 --- /dev/null +++ b/src/WireMock.Net.Minimal/ResponseBuilders/Response.WithWebSocket.cs @@ -0,0 +1,49 @@ +// Copyright © WireMock.Net + +using System; +using WireMock.Settings; +using WireMock.WebSockets; + +namespace WireMock.ResponseBuilders; + +public partial class Response +{ + /// + /// Internal property to store WebSocket builder configuration + /// + internal WebSocketBuilder? WebSocketBuilder { get; set; } + + /// + /// Configure WebSocket response behavior + /// + public IResponseBuilder WithWebSocket(Action configure) + { + var builder = new WebSocketBuilder(); + configure(builder); + + WebSocketBuilder = builder; + + return this; + } + + /// + /// Proxy WebSocket to another server + /// + public IResponseBuilder WithWebSocketProxy(string targetUrl) + { + return WithWebSocketProxy(new ProxyAndRecordSettings { Url = targetUrl }); + } + + /// + /// Proxy WebSocket to another server with settings + /// + public IResponseBuilder WithWebSocketProxy(ProxyAndRecordSettings settings) + { + var builder = new WebSocketBuilder(); + builder.WithProxy(settings); + + WebSocketBuilder = builder; + + return this; + } +} \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/ResponseMessage.cs b/src/WireMock.Net.Minimal/ResponseMessage.cs index e97cd929..3c269f64 100644 --- a/src/WireMock.Net.Minimal/ResponseMessage.cs +++ b/src/WireMock.Net.Minimal/ResponseMessage.cs @@ -8,6 +8,7 @@ using WireMock.ResponseBuilders; using WireMock.Types; using WireMock.Util; using Stef.Validation; +using WireMock.WebSockets; namespace WireMock; diff --git a/src/WireMock.Net.Minimal/ResponseProviders/WebSocketResponseProvider.cs b/src/WireMock.Net.Minimal/ResponseProviders/WebSocketResponseProvider.cs new file mode 100644 index 00000000..ce80fa8d --- /dev/null +++ b/src/WireMock.Net.Minimal/ResponseProviders/WebSocketResponseProvider.cs @@ -0,0 +1,345 @@ +// Copyright © WireMock.Net + +using System; +using System.Net; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Stef.Validation; +using WireMock.Constants; +using WireMock.Owin; +using WireMock.Settings; +using WireMock.WebSockets; + +namespace WireMock.ResponseProviders; + +internal class WebSocketResponseProvider : IResponseProvider +{ + private readonly WebSocketBuilder _builder; + + public WebSocketResponseProvider(WebSocketBuilder builder) + { + _builder = Guard.NotNull(builder); + } + + public async Task<(IResponseMessage Message, IMapping? Mapping)> ProvideResponseAsync( + IMapping mapping, + HttpContext context, + IRequestMessage requestMessage, + WireMockServerSettings settings) + { + // Check if this is a WebSocket upgrade request + if (!context.WebSockets.IsWebSocketRequest) + { + return (ResponseMessageBuilder.Create(HttpStatusCode.BadRequest, "Bad Request: Not a WebSocket upgrade request"), null); + } + + try + { + // Accept the WebSocket connection +#if NET8_0_OR_GREATER + var acceptContext = new WebSocketAcceptContext + { + SubProtocol = _builder.AcceptProtocol, + KeepAliveInterval = _builder.KeepAliveIntervalSeconds ?? TimeSpan.FromSeconds(WebSocketConstants.DefaultKeepAliveIntervalSeconds) + + }; + var webSocket = await context.WebSockets.AcceptWebSocketAsync(acceptContext).ConfigureAwait(false); +#else + var webSocket = await context.WebSockets.AcceptWebSocketAsync(_builder.AcceptProtocol).ConfigureAwait(false); +#endif + + // Get options from HttpContext.Items (set by WireMockMiddleware) + if (!context.Items.TryGetValue(nameof(WireMockMiddlewareOptions), out var optionsObj) || + optionsObj is not IWireMockMiddlewareOptions options) + { + throw new InvalidOperationException("WireMockMiddlewareOptions not found in HttpContext.Items"); + } + + // Get or create registry from options (not from server) + var registry = _builder.IsBroadcast + ? options.WebSocketRegistries.GetOrAdd(mapping.Guid, _ => new WebSocketConnectionRegistry()) + : null; + + // Create WebSocket context + var wsContext = new WireMockWebSocketContext( + context, + webSocket, + requestMessage, + mapping, + registry, + _builder + ); + + // Update scenario state following the same pattern as WireMockMiddleware + if (mapping.Scenario != null) + { + wsContext.UpdateScenarioState(); + } + + // Add to registry if broadcast is enabled + if (registry != null) + { + registry.AddConnection(wsContext); + } + + try + { + // Handle the WebSocket based on configuration + if (_builder.ProxySettings != null) + { + await HandleProxyAsync(wsContext, _builder.ProxySettings).ConfigureAwait(false); + } + else if (_builder.IsEcho) + { + await HandleEchoAsync(wsContext).ConfigureAwait(false); + } + else if (_builder.MessageHandler != null) + { + await HandleCustomAsync(wsContext, _builder.MessageHandler).ConfigureAwait(false); + } + else if (_builder.MessageSequence != null) + { + await HandleSequenceAsync(wsContext, _builder.MessageSequence).ConfigureAwait(false); + } + else + { + // Default: keep connection open until client closes + await WaitForCloseAsync(wsContext).ConfigureAwait(false); + } + } + finally + { + // Remove from registry + if (registry != null) + { + registry.RemoveConnection(wsContext.ConnectionId); + } + } + + // Return special marker to indicate WebSocket was handled + return (new WebSocketHandledResponse(), null); + } + catch (Exception ex) + { + settings.Logger?.Error($"WebSocket error for mapping '{mapping.Guid}': {ex.Message}", ex); + + // If we haven't upgraded yet, we can return HTTP error + if (!context.Response.HasStarted) + { + return (ResponseMessageBuilder.Create(HttpStatusCode.InternalServerError, $"WebSocket error: {ex.Message}"), null); + } + + // Already upgraded - return marker + return (new WebSocketHandledResponse(), null); + } + } + + private async Task HandleEchoAsync(WireMockWebSocketContext context) + { + var bufferSize = context.Builder.MaxMessageSize ?? WebSocketConstants.DefaultReceiveBufferSize; + var buffer = new byte[bufferSize]; + var timeout = context.Builder.CloseTimeout ?? TimeSpan.FromMinutes(WebSocketConstants.DefaultCloseTimeoutMinutes); + var cts = new CancellationTokenSource(timeout); + + try + { + while (context.WebSocket.State == WebSocketState.Open && !cts.Token.IsCancellationRequested) + { + var result = await context.WebSocket.ReceiveAsync( + new ArraySegment(buffer), + cts.Token + ).ConfigureAwait(false); + + if (result.MessageType == WebSocketMessageType.Close) + { + await context.CloseAsync( + WebSocketCloseStatus.NormalClosure, + "Closed by client" + ).ConfigureAwait(false); + break; + } + + // Echo back + await context.WebSocket.SendAsync( + new ArraySegment(buffer, 0, result.Count), + result.MessageType, + result.EndOfMessage, + cts.Token + ).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + if (context.WebSocket.State == WebSocketState.Open) + { + await context.CloseAsync(WebSocketCloseStatus.NormalClosure, "Timeout"); + } + } + } + + private async Task HandleCustomAsync( + WireMockWebSocketContext context, + Func handler) + { + var bufferSize = context.Builder.MaxMessageSize ?? WebSocketConstants.DefaultReceiveBufferSize; + var buffer = new byte[bufferSize]; + var timeout = context.Builder.CloseTimeout ?? TimeSpan.FromMinutes(WebSocketConstants.DefaultCloseTimeoutMinutes); + var cts = new CancellationTokenSource(timeout); + + try + { + while (context.WebSocket.State == WebSocketState.Open && !cts.Token.IsCancellationRequested) + { + var result = await context.WebSocket.ReceiveAsync( + new ArraySegment(buffer), + cts.Token + ).ConfigureAwait(false); + + if (result.MessageType == WebSocketMessageType.Close) + { + await context.CloseAsync( + WebSocketCloseStatus.NormalClosure, + "Closed by client" + ).ConfigureAwait(false); + break; + } + + var message = CreateWebSocketMessage(result, buffer); + + // Call custom handler + await handler(message, context).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + if (context.WebSocket.State == WebSocketState.Open) + { + await context.CloseAsync(WebSocketCloseStatus.NormalClosure, "Timeout"); + } + } + } + + private async Task HandleSequenceAsync(WireMockWebSocketContext context, WebSocketMessageSequence sequence) + { + await sequence.ExecuteAsync(context).ConfigureAwait(false); + } + + private async Task HandleProxyAsync(WireMockWebSocketContext context, ProxyAndRecordSettings settings) + { + using var clientWebSocket = new ClientWebSocket(); + + var targetUri = new Uri(settings.Url); + await clientWebSocket.ConnectAsync(targetUri, CancellationToken.None).ConfigureAwait(false); + + // Bidirectional proxy + var clientToServer = ForwardMessagesAsync(context.WebSocket, clientWebSocket); + var serverToClient = ForwardMessagesAsync(clientWebSocket, context.WebSocket); + + await Task.WhenAny(clientToServer, serverToClient).ConfigureAwait(false); + + // Close both + if (context.WebSocket.State == WebSocketState.Open) + { + await context.CloseAsync(WebSocketCloseStatus.NormalClosure, "Proxy closed"); + } + if (clientWebSocket.State == WebSocketState.Open) + { + await clientWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Proxy closed", CancellationToken.None); + } + } + + private async Task ForwardMessagesAsync(WebSocket source, WebSocket destination) + { + var buffer = new byte[WebSocketConstants.ProxyForwardBufferSize]; + + while (source.State == WebSocketState.Open && destination.State == WebSocketState.Open) + { + var result = await source.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + + if (result.MessageType == WebSocketMessageType.Close) + { + await destination.CloseAsync( + result.CloseStatus ?? WebSocketCloseStatus.NormalClosure, + result.CloseStatusDescription, + CancellationToken.None + ); + break; + } + + await destination.SendAsync( + new ArraySegment(buffer, 0, result.Count), + result.MessageType, + result.EndOfMessage, + CancellationToken.None + ); + } + } + + private async Task WaitForCloseAsync(WireMockWebSocketContext context) + { + var buffer = new byte[WebSocketConstants.MinimumBufferSize]; + var timeout = context.Builder.CloseTimeout ?? TimeSpan.FromMinutes(WebSocketConstants.DefaultCloseTimeoutMinutes); + var cts = new CancellationTokenSource(timeout); + + try + { + while (context.WebSocket.State == WebSocketState.Open && !cts.Token.IsCancellationRequested) + { + var result = await context.WebSocket.ReceiveAsync( + new ArraySegment(buffer), + cts.Token + ); + + if (result.MessageType == WebSocketMessageType.Close) + { + await context.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closed by client"); + break; + } + } + } + catch (OperationCanceledException) + { + if (context.WebSocket.State == WebSocketState.Open) + { + await context.CloseAsync(WebSocketCloseStatus.NormalClosure, "Timeout"); + } + } + } + + private static WebSocketMessage CreateWebSocketMessage(WebSocketReceiveResult result, byte[] buffer) + { + var message = new WebSocketMessage + { + MessageType = result.MessageType, + EndOfMessage = result.EndOfMessage, + Timestamp = DateTime.UtcNow + }; + + if (result.MessageType == WebSocketMessageType.Text) + { + message.Text = Encoding.UTF8.GetString(buffer, 0, result.Count); + } + else + { + message.Bytes = new byte[result.Count]; + Array.Copy(buffer, message.Bytes, result.Count); + } + + return message; + } +} + +/// +/// Special response marker to indicate WebSocket has been handled +/// +internal class WebSocketHandledResponse : ResponseMessage +{ + public WebSocketHandledResponse() + { + // 101 Switching Protocols + StatusCode = (int)HttpStatusCode.SwitchingProtocols; + } +} \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/Serialization/MappingFileNameSanitizer.cs b/src/WireMock.Net.Minimal/Serialization/MappingFileNameSanitizer.cs index e00651be..94cefa2b 100644 --- a/src/WireMock.Net.Minimal/Serialization/MappingFileNameSanitizer.cs +++ b/src/WireMock.Net.Minimal/Serialization/MappingFileNameSanitizer.cs @@ -30,7 +30,7 @@ public class MappingFileNameSanitizer if (!string.IsNullOrEmpty(mapping.Title)) { // remove 'Proxy Mapping for ' and an extra space character after the HTTP request method - name = mapping.Title.Replace(ProxyAndRecordSettings.DefaultPrefixForSavedMappingFile, "").Replace(' '.ToString(), string.Empty); + name = mapping.Title!.Replace(ProxyAndRecordSettings.DefaultPrefixForSavedMappingFile, "").Replace(' '.ToString(), string.Empty); if (_settings.ProxyAndRecordSettings?.AppendGuidToSavedMappingFile == true) { name += $"{ReplaceChar}{mapping.Guid}"; diff --git a/src/WireMock.Net.Minimal/Server/RespondWithAProvider.cs b/src/WireMock.Net.Minimal/Server/RespondWithAProvider.cs index 510252b8..58e86abc 100644 --- a/src/WireMock.Net.Minimal/Server/RespondWithAProvider.cs +++ b/src/WireMock.Net.Minimal/Server/RespondWithAProvider.cs @@ -37,7 +37,7 @@ internal class RespondWithAProvider : IRespondWithAProvider private int _timesInSameState = 1; private bool? _useWebhookFireAndForget; private double? _probability; - private GraphQLSchemaDetails? _graphQLSchemaDetails; + private GraphQLSchemaDetails? _graphQLSchemaDetails; // Future Use. public Guid Guid { get; private set; } @@ -79,6 +79,12 @@ internal class RespondWithAProvider : IRespondWithAProvider /// public void RespondWith(IResponseProvider provider) { + if (provider is Response response && response.WebSocketBuilder != null) + { + // If the provider is a Response with a WebSocketBuilder, we need to use a WebSocketResponseProvider instead. + provider = new WebSocketResponseProvider(response.WebSocketBuilder); + } + var mapping = new Mapping ( Guid, diff --git a/src/WireMock.Net.Minimal/Server/WireMockServer.AdminFiles.cs b/src/WireMock.Net.Minimal/Server/WireMockServer.AdminFiles.cs index 164970ea..a80e2ee2 100644 --- a/src/WireMock.Net.Minimal/Server/WireMockServer.AdminFiles.cs +++ b/src/WireMock.Net.Minimal/Server/WireMockServer.AdminFiles.cs @@ -106,7 +106,6 @@ public partial class WireMockServer /// Checks if file exists. /// Note: Response is returned with no body as a head request doesn't accept a body, only the status code. /// - /// The request message. private IResponseMessage FileHead(HttpContext _, IRequestMessage requestMessage) { var filename = GetFileNameFromRequestMessage(requestMessage); diff --git a/src/WireMock.Net.Minimal/Server/WireMockServer.WebSocket.cs b/src/WireMock.Net.Minimal/Server/WireMockServer.WebSocket.cs new file mode 100644 index 00000000..aef3f354 --- /dev/null +++ b/src/WireMock.Net.Minimal/Server/WireMockServer.WebSocket.cs @@ -0,0 +1,77 @@ +// Copyright © WireMock.Net + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.WebSockets; +using System.Threading.Tasks; +using JetBrains.Annotations; +using WireMock.WebSockets; + +namespace WireMock.Server; + +public partial class WireMockServer +{ + /// + /// Get all active WebSocket connections + /// + [PublicAPI] + public IReadOnlyCollection GetWebSocketConnections() + { + return _options.WebSocketRegistries.Values + .SelectMany(r => r.GetConnections()) + .ToList(); + } + + /// + /// Get WebSocket connections for a specific mapping + /// + [PublicAPI] + public IReadOnlyCollection GetWebSocketConnections(Guid mappingGuid) + { + return _options.WebSocketRegistries.TryGetValue(mappingGuid, out var registry) ? registry.GetConnections() : []; + } + + /// + /// Close a specific WebSocket connection + /// + [PublicAPI] + public async Task CloseWebSocketConnectionAsync( + Guid connectionId, + WebSocketCloseStatus closeStatus = WebSocketCloseStatus.NormalClosure, + string statusDescription = "Closed by server") + { + foreach (var registry in _options.WebSocketRegistries.Values) + { + if (registry.TryGetConnection(connectionId, out var connection)) + { + await connection.CloseAsync(closeStatus, statusDescription); + return; + } + } + } + + /// + /// Broadcast a message to all WebSocket connections in a specific mapping + /// + [PublicAPI] + public async Task BroadcastToWebSocketsAsync(Guid mappingGuid, string text) + { + if (_options.WebSocketRegistries.TryGetValue(mappingGuid, out var registry)) + { + await registry.BroadcastTextAsync(text); + } + } + + /// + /// Broadcast a message to all WebSocket connections + /// + [PublicAPI] + public async Task BroadcastToAllWebSocketsAsync(string text) + { + foreach (var registry in _options.WebSocketRegistries.Values) + { + await registry.BroadcastTextAsync(text); + } + } +} \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs b/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs index 6cd639a5..f038afab 100644 --- a/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs +++ b/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs @@ -7,6 +7,7 @@ using System.Globalization; using System.Linq; using JetBrains.Annotations; using Stef.Validation; +using WireMock.Constants; using WireMock.Logging; using WireMock.Models; using WireMock.Types; @@ -86,6 +87,7 @@ public static class WireMockServerSettingsParser ParseCertificateSettings(settings, parser); ParseHandlebarsSettings(settings, parser); ParseActivityTracingSettings(settings, parser); + ParseWebSocketSettings(settings, parser); return true; } @@ -242,4 +244,20 @@ public static class WireMockServerSettingsParser }; } } + + private static void ParseWebSocketSettings(WireMockServerSettings settings, SimpleSettingsParser parser) + { + // Check if any WebSocket setting is present + if (parser.ContainsAny( + nameof(WebSocketSettings) + '.' + nameof(WebSocketSettings.MaxConnections), + nameof(WebSocketSettings) + '.' + nameof(WebSocketSettings.KeepAliveIntervalSeconds)) + ) + { + settings.WebSocketSettings = new WebSocketSettings + { + MaxConnections = parser.GetIntValue(nameof(WebSocketSettings) + '.' + nameof(WebSocketSettings.MaxConnections), 100), + KeepAliveIntervalSeconds = parser.GetIntValue(nameof(WebSocketSettings) + '.' + nameof(WebSocketSettings.KeepAliveIntervalSeconds), WebSocketConstants.DefaultKeepAliveIntervalSeconds), + }; + } + } } \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/Util/TinyMapperUtils.cs b/src/WireMock.Net.Minimal/Util/TinyMapperUtils.cs index 5fcee4cd..70a19d36 100644 --- a/src/WireMock.Net.Minimal/Util/TinyMapperUtils.cs +++ b/src/WireMock.Net.Minimal/Util/TinyMapperUtils.cs @@ -1,5 +1,6 @@ // Copyright © WireMock.Net +using System.Diagnostics.CodeAnalysis; using Nelibur.ObjectMapper; using WireMock.Admin.Mappings; using WireMock.Admin.Settings; @@ -7,6 +8,7 @@ using WireMock.Settings; namespace WireMock.Util; +[SuppressMessage("Performance", "CA1822:Mark members as static")] internal sealed class TinyMapperUtils { public static TinyMapperUtils Instance { get; } = new(); @@ -22,6 +24,9 @@ internal sealed class TinyMapperUtils TinyMapper.Bind(); TinyMapper.Bind(); TinyMapper.Bind(); + + TinyMapper.Bind(); + TinyMapper.Bind(); } public ProxyAndRecordSettingsModel? Map(ProxyAndRecordSettings? instance) @@ -53,4 +58,14 @@ internal sealed class TinyMapperUtils { return model == null ? null : TinyMapper.Map(model); } + + public WebSocketSettingsModel? Map(WebSocketSettings? instance) + { + return instance == null ? null : TinyMapper.Map(instance); + } + + public WebSocketSettings? Map(WebSocketSettingsModel? model) + { + return model == null ? null : TinyMapper.Map(model); + } } \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/WebSockets/WebSocketBuilder.cs b/src/WireMock.Net.Minimal/WebSockets/WebSocketBuilder.cs new file mode 100644 index 00000000..6f24ca80 --- /dev/null +++ b/src/WireMock.Net.Minimal/WebSockets/WebSocketBuilder.cs @@ -0,0 +1,133 @@ +// Copyright © WireMock.Net + +using System; +using System.Threading.Tasks; +using Stef.Validation; +using WireMock.Settings; +using WireMock.Types; + +namespace WireMock.WebSockets; + +internal class WebSocketBuilder : IWebSocketBuilder +{ + /// + public string? AcceptProtocol { get; private set; } + + /// + public bool IsEcho { get; private set; } + + /// + public bool IsBroadcast { get; private set; } + + /// + public Func? MessageHandler { get; private set; } + + /// + public WebSocketMessageSequence? MessageSequence { get; private set; } + + /// + public ProxyAndRecordSettings? ProxySettings { get; private set; } + + /// + public TimeSpan? CloseTimeout { get; private set; } + + /// + public int? MaxMessageSize { get; private set; } + + /// + public int? ReceiveBufferSize { get; private set; } + + /// + public TimeSpan? KeepAliveIntervalSeconds { get; private set; } + + /// + public bool UseTransformer { get; private set; } + + /// + public TransformerType TransformerType { get; private set; } + + /// + public bool UseTransformerForBodyAsFile { get; private set; } + + /// + public ReplaceNodeOptions TransformerReplaceNodeOptions { get; private set; } + + public IWebSocketBuilder WithAcceptProtocol(string protocol) + { + AcceptProtocol = Guard.NotNull(protocol); + return this; + } + + public IWebSocketBuilder WithEcho() + { + IsEcho = true; + return this; + } + + public IWebSocketBuilder WithMessageHandler( + Func handler) + { + MessageHandler = Guard.NotNull(handler); + IsEcho = false; // Disable echo if custom handler is set + return this; + } + + public IWebSocketBuilder WithMessageSequence( + Action configure) + { + var sequenceBuilder = new WebSocketMessageSequenceBuilder(); + configure(sequenceBuilder); + MessageSequence = sequenceBuilder.Build(); + IsEcho = false; + return this; + } + + public IWebSocketBuilder WithBroadcast() + { + IsBroadcast = true; + return this; + } + + public IWebSocketBuilder WithProxy(ProxyAndRecordSettings settings) + { + ProxySettings = Guard.NotNull(settings); + IsEcho = false; + return this; + } + + public IWebSocketBuilder WithCloseTimeout(TimeSpan timeout) + { + CloseTimeout = timeout; + return this; + } + + public IWebSocketBuilder WithMaxMessageSize(int sizeInBytes) + { + MaxMessageSize = Guard.Condition(sizeInBytes, s => s > 0); + return this; + } + + public IWebSocketBuilder WithReceiveBufferSize(int sizeInBytes) + { + ReceiveBufferSize = Guard.Condition(sizeInBytes, s => s > 0); + return this; + } + + public IWebSocketBuilder WithKeepAliveInterval(TimeSpan interval) + { + KeepAliveIntervalSeconds = interval; + return this; + } + + public IWebSocketBuilder WithTransformer( + TransformerType transformerType = TransformerType.Handlebars, + bool useTransformerForBodyAsFile = false, + ReplaceNodeOptions transformerReplaceNodeOptions = ReplaceNodeOptions.EvaluateAndTryToConvert) + { + UseTransformer = true; + TransformerType = transformerType; + UseTransformerForBodyAsFile = useTransformerForBodyAsFile; + TransformerReplaceNodeOptions = transformerReplaceNodeOptions; + return this; + } +} \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/WebSockets/WebSocketConnectionRegistry.cs b/src/WireMock.Net.Minimal/WebSockets/WebSocketConnectionRegistry.cs new file mode 100644 index 00000000..c41eb696 --- /dev/null +++ b/src/WireMock.Net.Minimal/WebSockets/WebSocketConnectionRegistry.cs @@ -0,0 +1,74 @@ +// Copyright © WireMock.Net + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace WireMock.WebSockets; + +/// +/// Registry for managing WebSocket connections per mapping +/// +internal class WebSocketConnectionRegistry +{ + private readonly ConcurrentDictionary _connections = new(); + + /// + /// Add a connection to the registry + /// + public void AddConnection(WireMockWebSocketContext context) + { + _connections.TryAdd(context.ConnectionId, context); + } + + /// + /// Remove a connection from the registry + /// + public void RemoveConnection(Guid connectionId) + { + _connections.TryRemove(connectionId, out _); + } + + /// + /// Get all connections + /// + public IReadOnlyCollection GetConnections() + { + return _connections.Values.ToList(); + } + + /// + /// Try to get a specific connection + /// + public bool TryGetConnection(Guid connectionId, [NotNullWhen(true)] out WireMockWebSocketContext? connection) + { + return _connections.TryGetValue(connectionId, out connection); + } + + /// + /// Broadcast text to all connections + /// + public async Task BroadcastTextAsync(string text, CancellationToken cancellationToken = default) + { + var tasks = _connections.Values + .Where(c => c.WebSocket.State == WebSocketState.Open) + .Select(c => c.SendTextAsync(text, cancellationToken)); + + await Task.WhenAll(tasks); + } + + /// + /// Broadcast JSON to all connections + /// + public async Task BroadcastJsonAsync(object data, CancellationToken cancellationToken = default) + { + var json = JsonConvert.SerializeObject(data); + await BroadcastTextAsync(json, cancellationToken); + } +} \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/WebSockets/WebSocketMessageSequence.cs b/src/WireMock.Net.Minimal/WebSockets/WebSocketMessageSequence.cs new file mode 100644 index 00000000..1e5850b9 --- /dev/null +++ b/src/WireMock.Net.Minimal/WebSockets/WebSocketMessageSequence.cs @@ -0,0 +1,14 @@ +// Copyright © WireMock.Net + +using System.Threading.Tasks; + +namespace WireMock.WebSockets; + +// Placeholder classes for future implementation +internal class WebSocketMessageSequence +{ + public Task ExecuteAsync(WireMockWebSocketContext context) + { + return Task.CompletedTask; + } +} diff --git a/src/WireMock.Net.Minimal/WebSockets/WebSocketMessageSequenceBuilder.cs b/src/WireMock.Net.Minimal/WebSockets/WebSocketMessageSequenceBuilder.cs new file mode 100644 index 00000000..e8acd0f8 --- /dev/null +++ b/src/WireMock.Net.Minimal/WebSockets/WebSocketMessageSequenceBuilder.cs @@ -0,0 +1,11 @@ +// Copyright © WireMock.Net + +namespace WireMock.WebSockets; + +internal class WebSocketMessageSequenceBuilder : IWebSocketMessageSequenceBuilder +{ + public WebSocketMessageSequence Build() + { + return new WebSocketMessageSequence(); + } +} diff --git a/src/WireMock.Net.Minimal/WebSockets/WireMockWebSocketContext.cs b/src/WireMock.Net.Minimal/WebSockets/WireMockWebSocketContext.cs new file mode 100644 index 00000000..291728c7 --- /dev/null +++ b/src/WireMock.Net.Minimal/WebSockets/WireMockWebSocketContext.cs @@ -0,0 +1,195 @@ +// Copyright © WireMock.Net + +using System; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; +using Stef.Validation; +using WireMock.Owin; + +namespace WireMock.WebSockets; + +/// +/// WebSocket context implementation +/// +public class WireMockWebSocketContext : IWebSocketContext +{ + private readonly IWireMockMiddlewareOptions _options; + + /// + public Guid ConnectionId { get; } = Guid.NewGuid(); + + /// + public HttpContext HttpContext { get; } + + /// + public WebSocket WebSocket { get; } + + /// + public IRequestMessage RequestMessage { get; } + + /// + public IMapping Mapping { get; } + + internal WebSocketConnectionRegistry? Registry { get; } + internal WebSocketBuilder Builder { get; } + + /// + /// Creates a new WebSocketContext + /// + internal WireMockWebSocketContext( + HttpContext httpContext, + WebSocket webSocket, + IRequestMessage requestMessage, + IMapping mapping, + WebSocketConnectionRegistry? registry, + WebSocketBuilder builder) + { + HttpContext = Guard.NotNull(httpContext); + WebSocket = Guard.NotNull(webSocket); + RequestMessage = Guard.NotNull(requestMessage); + Mapping = Guard.NotNull(mapping); + Registry = registry; + Builder = Guard.NotNull(builder); + + // Get options from HttpContext + if (httpContext.Items.TryGetValue("WireMockMiddlewareOptions", out var options)) + { + _options = (IWireMockMiddlewareOptions)options!; + } + else + { + throw new InvalidOperationException("WireMockMiddlewareOptions not found in HttpContext.Items"); + } + } + + /// + public Task SendTextAsync(string text, CancellationToken cancellationToken = default) + { + var bytes = Encoding.UTF8.GetBytes(text); + return WebSocket.SendAsync( + new ArraySegment(bytes), + WebSocketMessageType.Text, + true, + cancellationToken + ); + } + + /// + public Task SendBytesAsync(byte[] bytes, CancellationToken cancellationToken = default) + { + return WebSocket.SendAsync( + new ArraySegment(bytes), + WebSocketMessageType.Binary, + true, + cancellationToken + ); + } + + /// + public Task SendJsonAsync(object data, CancellationToken cancellationToken = default) + { + var json = JsonConvert.SerializeObject(data); + return SendTextAsync(json, cancellationToken); + } + + /// + public Task CloseAsync(WebSocketCloseStatus closeStatus, string statusDescription) + { + return WebSocket.CloseAsync(closeStatus, statusDescription, CancellationToken.None); + } + + /// + public void SetScenarioState(string nextState) + { + SetScenarioState(nextState, null); + } + + /// + public void SetScenarioState(string nextState, string? description) + { + if (Mapping.Scenario == null) + { + return; + } + + // Use the same logic as WireMockMiddleware + if (_options.Scenarios.TryGetValue(Mapping.Scenario, out var scenarioState)) + { + // Directly set the next state (bypass counter logic for manual WebSocket state changes) + scenarioState.NextState = nextState; + scenarioState.Started = true; + scenarioState.Finished = nextState == null; + + // Reset counter when manually setting state + scenarioState.Counter = 0; + } + else + { + // Create new scenario state if it doesn't exist + _options.Scenarios.TryAdd(Mapping.Scenario, new ScenarioState + { + Name = Mapping.Scenario, + NextState = nextState, + Started = true, + Finished = nextState == null, + Counter = 0 + }); + } + } + + /// + /// Update scenario state following the same pattern as WireMockMiddleware.UpdateScenarioState + /// This is called automatically when the WebSocket connection is established. + /// + internal void UpdateScenarioState() + { + if (Mapping.Scenario == null) + { + return; + } + + // Ensure scenario exists + if (!_options.Scenarios.TryGetValue(Mapping.Scenario, out var scenario)) + { + return; + } + + // Follow exact same logic as WireMockMiddleware.UpdateScenarioState + // Increase the number of times this state has been executed + scenario.Counter++; + + // Only if the number of times this state is executed equals the required StateTimes, + // proceed to next state and reset the counter to 0 + if (scenario.Counter == (Mapping.TimesInSameState ?? 1)) + { + scenario.NextState = Mapping.NextState; + scenario.Counter = 0; + } + + // Else just update Started and Finished + scenario.Started = true; + scenario.Finished = Mapping.NextState == null; + } + + /// + public async Task BroadcastTextAsync(string text, CancellationToken cancellationToken = default) + { + if (Registry != null) + { + await Registry.BroadcastTextAsync(text, cancellationToken); + } + } + + /// + public async Task BroadcastJsonAsync(object data, CancellationToken cancellationToken = default) + { + if (Registry != null) + { + await Registry.BroadcastJsonAsync(data, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/WireMock.Net.Minimal.csproj b/src/WireMock.Net.Minimal/WireMock.Net.Minimal.csproj index ca2dbeb1..7b903a73 100644 --- a/src/WireMock.Net.Minimal/WireMock.Net.Minimal.csproj +++ b/src/WireMock.Net.Minimal/WireMock.Net.Minimal.csproj @@ -52,7 +52,7 @@ - + diff --git a/src/WireMock.Net.Shared/RequestBuilders/IHttpVersionBuilder.cs b/src/WireMock.Net.Shared/RequestBuilders/IHttpVersionBuilder.cs index f10b1a39..ba9e9ac3 100644 --- a/src/WireMock.Net.Shared/RequestBuilders/IHttpVersionBuilder.cs +++ b/src/WireMock.Net.Shared/RequestBuilders/IHttpVersionBuilder.cs @@ -1,14 +1,13 @@ // Copyright © WireMock.Net using WireMock.Matchers; -using WireMock.Matchers.Request; namespace WireMock.RequestBuilders; /// /// The HttpVersionBuilder interface. /// -public interface IHttpVersionBuilder : IRequestMatcher +public interface IHttpVersionBuilder : IWebSocketRequestBuilder { /// /// WithHttpVersion diff --git a/src/WireMock.Net.Shared/RequestBuilders/IWebSocketBuilder.cs b/src/WireMock.Net.Shared/RequestBuilders/IWebSocketBuilder.cs new file mode 100644 index 00000000..0a5528b3 --- /dev/null +++ b/src/WireMock.Net.Shared/RequestBuilders/IWebSocketBuilder.cs @@ -0,0 +1,15 @@ +// Copyright © WireMock.Net +using WireMock.Matchers.Request; + +namespace WireMock.RequestBuilders; + +/// +/// The BodyRequestBuilder interface. +/// +public interface IWebSocketRequestBuilder : IRequestMatcher +{ + /// + /// Match WebSocket upgrade with optional protocols. + /// + IRequestBuilder WithWebSocketUpgrade(params string[] protocols); +} \ No newline at end of file diff --git a/src/WireMock.Net.Shared/ResponseBuilders/ICallbackResponseBuilder.cs b/src/WireMock.Net.Shared/ResponseBuilders/ICallbackResponseBuilder.cs index 5a927542..8205f6fa 100644 --- a/src/WireMock.Net.Shared/ResponseBuilders/ICallbackResponseBuilder.cs +++ b/src/WireMock.Net.Shared/ResponseBuilders/ICallbackResponseBuilder.cs @@ -3,14 +3,13 @@ using System; using System.Threading.Tasks; using JetBrains.Annotations; -using WireMock.ResponseProviders; namespace WireMock.ResponseBuilders; /// /// The CallbackResponseBuilder interface. /// -public interface ICallbackResponseBuilder : IResponseProvider +public interface ICallbackResponseBuilder : IWebSocketResponseBuilder { /// /// The callback builder diff --git a/src/WireMock.Net.Shared/ResponseBuilders/IWebSocketResponseBuilder.cs b/src/WireMock.Net.Shared/ResponseBuilders/IWebSocketResponseBuilder.cs new file mode 100644 index 00000000..47e65290 --- /dev/null +++ b/src/WireMock.Net.Shared/ResponseBuilders/IWebSocketResponseBuilder.cs @@ -0,0 +1,29 @@ +// Copyright © WireMock.Net + +using System; +using WireMock.ResponseProviders; +using WireMock.Settings; +using WireMock.WebSockets; + +namespace WireMock.ResponseBuilders; + +/// +/// The WebSocketResponseBuilder interface. +/// +public interface IWebSocketResponseBuilder : IResponseProvider +{ + /// + /// Configure WebSocket response behavior + /// + IResponseBuilder WithWebSocket(Action configure); + + /// + /// Proxy WebSocket to another server + /// + IResponseBuilder WithWebSocketProxy(string targetUrl); + + /// + /// Proxy WebSocket to another server with settings + /// + IResponseBuilder WithWebSocketProxy(ProxyAndRecordSettings settings); +} \ No newline at end of file diff --git a/src/WireMock.Net.Shared/Settings/WebSocketSettings.cs b/src/WireMock.Net.Shared/Settings/WebSocketSettings.cs new file mode 100644 index 00000000..a3ce6466 --- /dev/null +++ b/src/WireMock.Net.Shared/Settings/WebSocketSettings.cs @@ -0,0 +1,21 @@ +// Copyright © WireMock.Net + +using WireMock.Constants; + +namespace WireMock.Settings; + +/// +/// WebSocket-specific settings +/// +public class WebSocketSettings +{ + /// + /// Maximum number of concurrent WebSocket connections (default: 100) + /// + public int MaxConnections { get; set; } = 100; + + /// + /// Default keep-alive interval (default: 30 seconds) + /// + public int KeepAliveIntervalSeconds { get; set; } = WebSocketConstants.DefaultKeepAliveIntervalSeconds; +} \ No newline at end of file diff --git a/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs b/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs index 9df15904..3b563c65 100644 --- a/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs +++ b/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs @@ -346,4 +346,10 @@ public class WireMockServerSettings /// [PublicAPI] public ActivityTracingOptions? ActivityTracingOptions { get; set; } + + /// + /// WebSocket settings. + /// + [PublicAPI] + public WebSocketSettings? WebSocketSettings { get; set; } } \ No newline at end of file diff --git a/src/WireMock.Net.Shared/WebSockets/IWebSocketBuilder.cs b/src/WireMock.Net.Shared/WebSockets/IWebSocketBuilder.cs new file mode 100644 index 00000000..0f362d2d --- /dev/null +++ b/src/WireMock.Net.Shared/WebSockets/IWebSocketBuilder.cs @@ -0,0 +1,84 @@ +// Copyright © WireMock.Net + +using System; +using System.Threading.Tasks; +using JetBrains.Annotations; +using WireMock.Settings; +using WireMock.Types; + +namespace WireMock.WebSockets; + +/// +/// WebSocket Response Builder interface +/// +public interface IWebSocketBuilder +{ + /// + /// Accept the WebSocket with a specific protocol + /// + [PublicAPI] + IWebSocketBuilder WithAcceptProtocol(string protocol); + + /// + /// Echo all received messages back to client + /// + [PublicAPI] + IWebSocketBuilder WithEcho(); + + /// + /// Handle incoming WebSocket messages + /// + [PublicAPI] + IWebSocketBuilder WithMessageHandler(Func handler); + + /// + /// Define a sequence of messages to send + /// + [PublicAPI] + IWebSocketBuilder WithMessageSequence(Action configure); + + /// + /// Enable broadcast mode for this mapping + /// + [PublicAPI] + IWebSocketBuilder WithBroadcast(); + + /// + /// Proxy to another WebSocket server + /// + [PublicAPI] + IWebSocketBuilder WithProxy(ProxyAndRecordSettings settings); + + /// + /// Set close timeout (default: 10 minutes) + /// + [PublicAPI] + IWebSocketBuilder WithCloseTimeout(TimeSpan timeout); + + /// + /// Set maximum message size in bytes (default: 1 MB) + /// + [PublicAPI] + IWebSocketBuilder WithMaxMessageSize(int sizeInBytes); + + /// + /// Set receive buffer size (default: 4096 bytes) + /// + [PublicAPI] + IWebSocketBuilder WithReceiveBufferSize(int sizeInBytes); + + /// + /// Set keep-alive interval (default: 30 seconds) + /// + [PublicAPI] + IWebSocketBuilder WithKeepAliveInterval(TimeSpan interval); + + /// + /// Enable transformer support (Handlebars/Scriban) + /// + [PublicAPI] + IWebSocketBuilder WithTransformer( + TransformerType transformerType = TransformerType.Handlebars, + bool useTransformerForBodyAsFile = false, + ReplaceNodeOptions transformerReplaceNodeOptions = ReplaceNodeOptions.EvaluateAndTryToConvert); +} \ No newline at end of file diff --git a/src/WireMock.Net.Shared/WebSockets/IWebSocketContext.cs b/src/WireMock.Net.Shared/WebSockets/IWebSocketContext.cs new file mode 100644 index 00000000..b8bf4de4 --- /dev/null +++ b/src/WireMock.Net.Shared/WebSockets/IWebSocketContext.cs @@ -0,0 +1,85 @@ +// Copyright © WireMock.Net + +using System; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace WireMock.WebSockets; + +/// +/// WebSocket context interface for handling WebSocket connections +/// +public interface IWebSocketContext +{ + /// + /// Unique connection identifier + /// + Guid ConnectionId { get; } + + /// + /// The ASP.NET Core HttpContext + /// + HttpContext HttpContext { get; } + + /// + /// The WebSocket instance + /// + WebSocket WebSocket { get; } + + /// + /// The original request that initiated the WebSocket connection + /// + IRequestMessage RequestMessage { get; } + + /// + /// The mapping that matched this WebSocket request + /// + IMapping Mapping { get; } + + /// + /// Send text message to the client + /// + Task SendTextAsync(string text, CancellationToken cancellationToken = default); + + /// + /// Send binary message to the client + /// + Task SendBytesAsync(byte[] bytes, CancellationToken cancellationToken = default); + + /// + /// Send JSON message to the client + /// + Task SendJsonAsync(object data, CancellationToken cancellationToken = default); + + /// + /// Close the WebSocket connection + /// + Task CloseAsync(WebSocketCloseStatus closeStatus, string statusDescription); + + /// + /// Manually set the scenario state. This bypasses the counter logic and directly sets the next state. + /// Use this for programmatic state changes during WebSocket sessions. + /// + /// The next state to transition to + void SetScenarioState(string nextState); + + /// + /// Manually set the scenario state with description. This bypasses the counter logic and directly sets the next state. + /// Use this for programmatic state changes during WebSocket sessions. + /// + /// The next state to transition to + /// Optional description for logging + void SetScenarioState(string nextState, string? description); + + /// + /// Broadcast text message to all connections in this mapping + /// + Task BroadcastTextAsync(string text, CancellationToken cancellationToken = default); + + /// + /// Broadcast JSON message to all connections in this mapping + /// + Task BroadcastJsonAsync(object data, CancellationToken cancellationToken = default); +} diff --git a/src/WireMock.Net.Shared/WebSockets/IWebSocketMessageSequenceBuilder.cs b/src/WireMock.Net.Shared/WebSockets/IWebSocketMessageSequenceBuilder.cs new file mode 100644 index 00000000..bb5a229a --- /dev/null +++ b/src/WireMock.Net.Shared/WebSockets/IWebSocketMessageSequenceBuilder.cs @@ -0,0 +1,11 @@ +// Copyright © WireMock.Net + +namespace WireMock.WebSockets; + +/// +/// WebSocket Message Sequence Builder interface (placeholder for future implementation) +/// +public interface IWebSocketMessageSequenceBuilder +{ + // Future: Methods for building message sequences +} diff --git a/src/WireMock.Net.Shared/WebSockets/WebSocketMessage.cs b/src/WireMock.Net.Shared/WebSockets/WebSocketMessage.cs new file mode 100644 index 00000000..9dd0da98 --- /dev/null +++ b/src/WireMock.Net.Shared/WebSockets/WebSocketMessage.cs @@ -0,0 +1,37 @@ +// Copyright © WireMock.Net + +using System; +using System.Net.WebSockets; + +namespace WireMock.WebSockets; + +/// +/// Represents a WebSocket message +/// +public class WebSocketMessage +{ + /// + /// The message type (Text or Binary) + /// + public WebSocketMessageType MessageType { get; set; } + + /// + /// Text content (when MessageType is Text) + /// + public string? Text { get; set; } + + /// + /// Binary content (when MessageType is Binary) + /// + public byte[]? Bytes { get; set; } + + /// + /// Indicates whether this is the final message + /// + public bool EndOfMessage { get; set; } + + /// + /// Timestamp when the message was received + /// + public DateTime Timestamp { get; set; } +}