Add WebSockets (#1423)

* Add WebSockets

* Add tests

* fix

* more tests

* Add tests

* ...

* remove IOwin

* -

* tests

* fluent

* ok

* match

* .

* byte[]

* x

* func

* func

* byte

* trans

* ...

* frameworks.........

* jmes

* xxx

* sc
This commit is contained in:
Stef Heyenrath
2026-02-14 08:42:40 +01:00
committed by GitHub
parent dff55e175b
commit 8b27da95a8
103 changed files with 72659 additions and 398 deletions

View File

@@ -1,18 +0,0 @@
// Copyright © WireMock.Net
#if NET48
using System.Text.RegularExpressions;
using WireMock.Constants;
// ReSharper disable once CheckNamespace
namespace System;
internal static class StringExtensions
{
public static string Replace(this string text, string oldValue, string newValue, StringComparison stringComparison)
{
var options = stringComparison == StringComparison.OrdinalIgnoreCase ? RegexOptions.IgnoreCase : RegexOptions.None;
return Regex.Replace(text, oldValue, newValue, options, RegexConstants.DefaultTimeout);
}
}
#endif

View File

@@ -56,7 +56,11 @@ internal static class HttpClientBuilder
}
}
#if NET8_0_OR_GREATER
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls13 | SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls;
#else
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls;
#endif
ServicePointManager.ServerCertificateValidationCallback = (message, cert, chain, errors) => true;
return HttpClientFactory2.Create(handler);

View File

@@ -28,7 +28,7 @@ internal static class HttpClientFactory2
var next = handler;
foreach (var delegatingHandler in delegatingHandlers.Reverse())
foreach (var delegatingHandler in Enumerable.Reverse(delegatingHandlers))
{
delegatingHandler.InnerHandler = next;
next = delegatingHandler;

View File

@@ -0,0 +1,86 @@
// Copyright © WireMock.Net
using System;
using Stef.Validation;
using WireMock.Extensions;
namespace WireMock.Matchers;
/// <summary>
/// FuncMatcher - matches using a custom function
/// </summary>
/// <inheritdoc cref="IFuncMatcher"/>
public class FuncMatcher : IFuncMatcher
{
private readonly Func<string?, bool>? _stringFunc;
private readonly Func<byte[]?, bool>? _bytesFunc;
/// <inheritdoc />
public MatchBehaviour MatchBehaviour { get; }
/// <summary>
/// Initializes a new instance of the <see cref="FuncMatcher"/> class for string matching.
/// </summary>
/// <param name="func">The function to check if a string is a match.</param>
/// <param name="matchBehaviour">The match behaviour.</param>
public FuncMatcher(Func<string?, bool> func, MatchBehaviour matchBehaviour = MatchBehaviour.AcceptOnMatch)
{
_stringFunc = Guard.NotNull(func);
MatchBehaviour = matchBehaviour;
}
/// <summary>
/// Initializes a new instance of the <see cref="FuncMatcher"/> class for byte array matching.
/// </summary>
/// <param name="func">The function to check if a byte[] is a match.</param>
/// <param name="matchBehaviour">The match behaviour.</param>
public FuncMatcher(Func<byte[]?, bool> func, MatchBehaviour matchBehaviour = MatchBehaviour.AcceptOnMatch)
{
_bytesFunc = Guard.NotNull(func);
MatchBehaviour = matchBehaviour;
}
/// <inheritdoc />
public MatchResult IsMatch(object? value)
{
if (value is string stringValue && _stringFunc != null)
{
try
{
return MatchResult.From(Name, MatchBehaviour, _stringFunc(stringValue));
}
catch (Exception ex)
{
return MatchResult.From(Name, ex);
}
}
if (value is byte[] bytesValue && _bytesFunc != null)
{
try
{
return MatchResult.From(Name, MatchBehaviour, _bytesFunc(bytesValue));
}
catch (Exception ex)
{
return MatchResult.From(Name, ex);
}
}
return MatchResult.From(Name, MatchScores.Mismatch);
}
/// <inheritdoc />
public string Name => nameof(FuncMatcher);
/// <inheritdoc />
public string GetCSharpCodeArguments()
{
var funcType = _stringFunc != null ? "Func<string?, bool>" : "Func<byte[]?, bool>";
return $"new {Name}" +
$"(" +
$"/* {funcType} function */, " +
$"{MatchBehaviour.GetFullyQualifiedEnumValue()}" +
$")";
}
}

View File

@@ -1,14 +1,12 @@
// Copyright © WireMock.Net
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Stef.Validation;
using WireMock.Extensions;
using WireMock.Logging;
using WireMock.Owin.Mappers;
using WireMock.Services;
@@ -16,7 +14,7 @@ using WireMock.Util;
namespace WireMock.Owin;
internal partial class AspNetCoreSelfHost : IOwinSelfHost
internal partial class AspNetCoreSelfHost
{
private readonly CancellationTokenSource _cts = new();
private readonly IWireMockMiddlewareOptions _wireMockMiddlewareOptions;
@@ -27,9 +25,9 @@ internal partial class AspNetCoreSelfHost : IOwinSelfHost
public bool IsStarted { get; private set; }
public List<string> Urls { get; } = new();
public List<string> Urls { get; } = [];
public List<int> Ports { get; } = new();
public List<int> Ports { get; } = [];
public Exception? RunningException { get; private set; }
@@ -80,6 +78,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);
@@ -112,14 +118,42 @@ internal partial class AspNetCoreSelfHost : IOwinSelfHost
{
var addresses = _host.ServerFeatures
.Get<Microsoft.AspNetCore.Hosting.Server.Features.IServerAddressesFeature>()!
.Addresses;
.Addresses
.ToArray();
foreach (var address in addresses)
if (_urlOptions.Urls == null)
{
Urls.Add(address.Replace("0.0.0.0", "localhost").Replace("[::]", "localhost"));
foreach (var address in addresses)
{
PortUtils.TryExtract(address, out _, out _, out var scheme, out var host, out var port);
PortUtils.TryExtract(address, out _, out _, out _, out _, out var port);
Ports.Add(port);
var replacedHost = ReplaceHostWithLocalhost(host!);
var newUrl = $"{scheme}://{replacedHost}:{port}";
Urls.Add(newUrl);
Ports.Add(port);
}
}
else
{
var urlOptions = _urlOptions.Urls?.ToArray() ?? [];
for (int i = 0; i < urlOptions.Length; i++)
{
PortUtils.TryExtract(urlOptions[i], out _, out _, out var originalScheme, out _, out _);
if (originalScheme!.StartsWith("grpc", StringComparison.OrdinalIgnoreCase))
{
// Always replace "grpc" with "http" in the scheme because GrpcChannel needs http or https.
originalScheme = originalScheme.Replace("grpc", "http", StringComparison.OrdinalIgnoreCase);
}
PortUtils.TryExtract(addresses[i], out _, out _, out _, out var realHost, out var realPort);
var replacedHost = ReplaceHostWithLocalhost(realHost!);
var newUrl = $"{originalScheme}://{replacedHost}:{realPort}";
Urls.Add(newUrl);
Ports.Add(realPort);
}
}
IsStarted = true;
@@ -127,8 +161,8 @@ internal partial class AspNetCoreSelfHost : IOwinSelfHost
#if NET8_0
_logger.Info("Server using .NET 8.0");
#elif NET48
_logger.Info("Server using .NET Framework 4.8");
#else
_logger.Info("Server using .NET Standard 2.0");
#endif
return _host.RunAsync(token);
@@ -151,4 +185,9 @@ internal partial class AspNetCoreSelfHost : IOwinSelfHost
IsStarted = false;
return _host.StopAsync();
}
private static string ReplaceHostWithLocalhost(string host)
{
return host.Replace("0.0.0.0", "localhost").Replace("[::]", "localhost");
}
}

View File

@@ -1,6 +1,5 @@
// Copyright © WireMock.Net
using System.Collections.Generic;
using WireMock.Types;
using WireMock.Util;
@@ -23,20 +22,34 @@ internal class HostUrlOptions
var list = new List<HostUrlDetails>();
if (Urls == null)
{
if (HostingScheme is HostingScheme.Http or HostingScheme.Https)
if (HostingScheme is not HostingScheme.None)
{
var port = Port > 0 ? Port.Value : FindFreeTcpPort();
var scheme = HostingScheme == HostingScheme.Https ? "https" : "http";
list.Add(new HostUrlDetails { IsHttps = HostingScheme == HostingScheme.Https, IsHttp2 = UseHttp2 == true, Url = $"{scheme}://{Star}:{port}", Scheme = scheme, Host = Star, Port = port });
var scheme = GetSchemeAsString(HostingScheme);
var port = Port > 0 ? Port.Value : 0;
var isHttps = HostingScheme == HostingScheme.Https || HostingScheme == HostingScheme.Wss;
list.Add(new HostUrlDetails { IsHttps = isHttps, IsHttp2 = UseHttp2 == true, Url = $"{scheme}://{Star}:{port}", Scheme = scheme, Host = Star, Port = port });
}
if (HostingScheme == HostingScheme.HttpAndHttps)
{
var httpPort = Port > 0 ? Port.Value : FindFreeTcpPort();
list.Add(new HostUrlDetails { IsHttps = false, IsHttp2 = UseHttp2 == true, Url = $"http://{Star}:{httpPort}", Scheme = "http", Host = Star, Port = httpPort });
var port = Port > 0 ? Port.Value : 0;
var scheme = GetSchemeAsString(HostingScheme.Http);
list.Add(new HostUrlDetails { IsHttps = false, IsHttp2 = UseHttp2 == true, Url = $"{scheme}://{Star}:{port}", Scheme = scheme, Host = Star, Port = port });
var httpsPort = FindFreeTcpPort(); // In this scenario, always get a free port for https.
list.Add(new HostUrlDetails { IsHttps = true, IsHttp2 = UseHttp2 == true, Url = $"https://{Star}:{httpsPort}", Scheme = "https", Host = Star, Port = httpsPort });
var securePort = 0; // In this scenario, always get a free port for https.
var secureScheme = GetSchemeAsString(HostingScheme.Https);
list.Add(new HostUrlDetails { IsHttps = true, IsHttp2 = UseHttp2 == true, Url = $"{secureScheme}://{Star}:{securePort}", Scheme = secureScheme, Host = Star, Port = securePort });
}
if (HostingScheme == HostingScheme.WsAndWss)
{
var port = Port > 0 ? Port.Value : 0;
var scheme = GetSchemeAsString(HostingScheme.Ws);
list.Add(new HostUrlDetails { IsHttps = false, IsHttp2 = UseHttp2 == true, Url = $"{scheme}://{Star}:{port}", Scheme = scheme, Host = Star, Port = port });
var securePort = 0; // In this scenario, always get a free port for https.
var secureScheme = GetSchemeAsString(HostingScheme.Wss);
list.Add(new HostUrlDetails { IsHttps = true, IsHttp2 = UseHttp2 == true, Url = $"{secureScheme}://{Star}:{securePort}", Scheme = secureScheme, Host = Star, Port = securePort });
}
}
else
@@ -53,12 +66,19 @@ internal class HostUrlOptions
return list;
}
private static int FindFreeTcpPort()
private string GetSchemeAsString(HostingScheme scheme)
{
//#if USE_ASPNETCORE || NETSTANDARD2_0 || NETSTANDARD2_1
return 0;
//#else
//return PortUtils.FindFreeTcpPort();
//#endif
return scheme switch
{
HostingScheme.Http => "http",
HostingScheme.Https => "https",
HostingScheme.HttpAndHttps => "http", // Default to http when both are specified, since the https URL will be added separately with a free port.
HostingScheme.Ws => "ws",
HostingScheme.Wss => "wss",
HostingScheme.WsAndWss => "ws", // Default to ws when both are specified, since the wss URL will be added separately with a free port.
_ => throw new NotSupportedException($"Unsupported hosting scheme: {HostingScheme}")
};
}
}

View File

@@ -1,37 +0,0 @@
// Copyright © WireMock.Net
using System.Collections.Generic;
using System.Threading.Tasks;
using System;
namespace WireMock.Owin;
interface IOwinSelfHost
{
/// <summary>
/// Gets a value indicating whether this server is started.
/// </summary>
/// <value>
/// <c>true</c> if this server is started; otherwise, <c>false</c>.
/// </value>
bool IsStarted { get; }
/// <summary>
/// Gets the urls.
/// </summary>
List<string> Urls { get; }
/// <summary>
/// Gets the ports.
/// </summary>
List<int> Ports { get; }
/// <summary>
/// The exception occurred when the host is running.
/// </summary>
Exception? RunningException { get; }
Task StartAsync();
Task StopAsync();
}

View File

@@ -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; }
/// <summary>
/// Gets or sets the activity tracing options.
/// When set, System.Diagnostics.Activity objects are created for request tracing.
/// </summary>
ActivityTracingOptions? ActivityTracingOptions { get; set; }
/// <summary>
/// The WebSocket connection registries per mapping (used for broadcast).
/// </summary>
ConcurrentDictionary<Guid, WebSocketConnectionRegistry> WebSocketRegistries { get; }
/// <summary>
/// WebSocket settings.
/// </summary>
WebSocketSettings? WebSocketSettings { get; set; }
}

View File

@@ -1,12 +1,6 @@
// Copyright © WireMock.Net
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
//#if !USE_ASPNETCORE
//using IRequest = Microsoft.Owin.IOwinRequest;
//#else
//using IRequest = Microsoft.AspNetCore.Http.HttpRequest;
//#endif
namespace WireMock.Owin.Mappers;
@@ -18,8 +12,8 @@ internal interface IOwinRequestMapper
/// <summary>
/// MapAsync IRequest to RequestMessage
/// </summary>
/// <param name="request">The HttpRequest</param>
/// <param name="context">The HttpContext</param>
/// <param name="options">The WireMockMiddlewareOptions</param>
/// <returns>RequestMessage</returns>
Task<RequestMessage> MapAsync(HttpRequest request, IWireMockMiddlewareOptions options);
Task<RequestMessage> MapAsync(HttpContext context, IWireMockMiddlewareOptions options);
}

View File

@@ -1,9 +1,6 @@
// Copyright © WireMock.Net
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using WireMock.Http;
@@ -18,8 +15,9 @@ namespace WireMock.Owin.Mappers;
internal class OwinRequestMapper : IOwinRequestMapper
{
/// <inheritdoc />
public async Task<RequestMessage> MapAsync(HttpRequest request, IWireMockMiddlewareOptions options)
public async Task<RequestMessage> MapAsync(HttpContext context, IWireMockMiddlewareOptions options)
{
var request = context.Request;
var (urlDetails, clientIP) = ParseRequest(request);
var method = request.Method;

View File

@@ -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
/// <inheritdoc />
public async Task MapAsync(IResponseMessage? responseMessage, HttpResponse response)
{
if (responseMessage == null)
if (responseMessage == null || responseMessage is WebSocketHandledResponse)
{
return;
}

View File

@@ -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,7 +65,10 @@ internal class WireMockMiddleware
private async Task InvokeInternalAsync(HttpContext ctx)
{
var request = await _requestMapper.MapAsync(ctx.Request, _options).ConfigureAwait(false);
// Store options in HttpContext for providers to access (e.g., WebSocketResponseProvider)
ctx.Items[nameof(WireMockMiddlewareOptions)] = _options;
var request = await _requestMapper.MapAsync(ctx, _options).ConfigureAwait(false);
var logRequest = false;
IResponseMessage? response = null;
@@ -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)

View File

@@ -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<IApplicationBuilder>? PostWireMockMiddlewareInit { get; set; }
//#if USE_ASPNETCORE
public Action<IServiceCollection>? AdditionalServiceRegistration { get; set; }
public CorsPolicyOptions? CorsPolicyOptions { get; set; }
@@ -49,7 +48,6 @@ internal class WireMockMiddlewareOptions : IWireMockMiddlewareOptions
/// <inheritdoc />
public bool AcceptAnyClientCertificate { get; set; }
//#endif
/// <inheritdoc cref="IWireMockMiddlewareOptions.FileSystemHandler"/>
public IFileSystemHandler? FileSystemHandler { get; set; }
@@ -107,4 +105,9 @@ internal class WireMockMiddlewareOptions : IWireMockMiddlewareOptions
/// <inheritdoc />
public ActivityTracingOptions? ActivityTracingOptions { get; set; }
/// <inheritdoc />
public ConcurrentDictionary<Guid, WebSocketConnectionRegistry> WebSocketRegistries { get; } = new();
public WebSocketSettings? WebSocketSettings { get; set; }
}

View File

@@ -1,6 +1,6 @@
// Copyright © WireMock.Net
using System;
using WireMock.Extensions;
using WireMock.Settings;
using WireMock.Transformers;

View File

@@ -0,0 +1,48 @@
// Copyright © WireMock.Net
using System.Linq;
using WireMock.Matchers;
using WireMock.Matchers.Request;
namespace WireMock.RequestBuilders;
public partial class Request
{
/// <inheritdoc />
public bool IsWebSocket { get; private set; }
/// <inheritdoc />
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()
));
}
IsWebSocket = true;
return this;
}
}

View File

@@ -2,8 +2,6 @@
// This source file is based on mock4net by Alexandre Victoor which is licensed under the Apache 2.0 License.
// For more details see 'mock4net/LICENSE.txt' and 'mock4net/readme.md' in this project root.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;

View File

@@ -0,0 +1,48 @@
// Copyright © WireMock.Net
using WireMock.Settings;
using WireMock.WebSockets;
namespace WireMock.ResponseBuilders;
public partial class Response
{
/// <summary>
/// Internal property to store WebSocket builder configuration
/// </summary>
internal WebSocketBuilder? WebSocketBuilder { get; set; }
/// <summary>
/// Configure WebSocket response behavior
/// </summary>
public IResponseBuilder WithWebSocket(Action<IWebSocketBuilder> configure)
{
var builder = new WebSocketBuilder(this);
configure(builder);
WebSocketBuilder = builder;
return this;
}
/// <summary>
/// Proxy WebSocket to another server
/// </summary>
public IResponseBuilder WithWebSocketProxy(string targetUrl)
{
return WithWebSocketProxy(new ProxyAndRecordSettings { Url = targetUrl });
}
/// <summary>
/// Proxy WebSocket to another server with settings
/// </summary>
public IResponseBuilder WithWebSocketProxy(ProxyAndRecordSettings settings)
{
var builder = new WebSocketBuilder(this);
builder.WithProxy(settings);
WebSocketBuilder = builder;
return this;
}
}

View File

@@ -8,6 +8,7 @@ using WireMock.ResponseBuilders;
using WireMock.Types;
using WireMock.Util;
using Stef.Validation;
using WireMock.WebSockets;
namespace WireMock;

View File

@@ -0,0 +1,17 @@
// Copyright © WireMock.Net
using System.Net;
namespace WireMock.ResponseProviders;
/// <summary>
/// Special response marker to indicate WebSocket has been handled
/// </summary>
internal class WebSocketHandledResponse : ResponseMessage
{
public WebSocketHandledResponse()
{
// 101 Switching Protocols
StatusCode = (int)HttpStatusCode.SwitchingProtocols;
}
}

View File

@@ -0,0 +1,315 @@
// Copyright © WireMock.Net
using System.Buffers;
using System.Net;
using System.Net.WebSockets;
using System.Text;
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(WebSocketBuilder builder) : IResponseProvider
{
private readonly WebSocketBuilder _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
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
{
// Default: keep connection open until client closes
await WaitForCloseAsync(wsContext).ConfigureAwait(false);
}
}
finally
{
// Remove from registry
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 static async Task HandleEchoAsync(WireMockWebSocketContext context)
{
var bufferSize = context.Builder.MaxMessageSize ?? WebSocketConstants.DefaultReceiveBufferSize;
using var buffer = ArrayPool<byte>.Shared.Lease(bufferSize);
var timeout = context.Builder.CloseTimeout ?? TimeSpan.FromMinutes(WebSocketConstants.DefaultCloseTimeoutMinutes);
using var cts = new CancellationTokenSource(timeout);
try
{
while (context.WebSocket.State == WebSocketState.Open && !cts.Token.IsCancellationRequested)
{
var result = await context.WebSocket.ReceiveAsync(
new ArraySegment<byte>(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<byte>(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 static async Task HandleCustomAsync(
WireMockWebSocketContext context,
Func<WebSocketMessage, IWebSocketContext, Task> handler)
{
var bufferSize = context.Builder.MaxMessageSize ?? WebSocketConstants.DefaultReceiveBufferSize;
var buffer = new byte[bufferSize];
var timeout = context.Builder.CloseTimeout ?? TimeSpan.FromMinutes(WebSocketConstants.DefaultCloseTimeoutMinutes);
using var cts = new CancellationTokenSource(timeout);
try
{
while (context.WebSocket.State == WebSocketState.Open && !cts.Token.IsCancellationRequested)
{
var result = await context.WebSocket.ReceiveAsync(
new ArraySegment<byte>(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 static 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 static 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<byte>(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<byte>(buffer, 0, result.Count),
result.MessageType,
result.EndOfMessage,
CancellationToken.None
);
}
}
private static async Task WaitForCloseAsync(WireMockWebSocketContext context)
{
var buffer = new byte[WebSocketConstants.MinimumBufferSize];
var timeout = context.Builder.CloseTimeout ?? TimeSpan.FromMinutes(WebSocketConstants.DefaultCloseTimeoutMinutes);
using var cts = new CancellationTokenSource(timeout);
try
{
while (context.WebSocket.State == WebSocketState.Open && !cts.Token.IsCancellationRequested)
{
var result = await context.WebSocket.ReceiveAsync(
new ArraySegment<byte>(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;
}
}

View File

@@ -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}";

View File

@@ -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
/// <inheritdoc />
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,

View File

@@ -1,8 +1,6 @@
// Copyright © WireMock.Net
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
@@ -702,7 +700,7 @@ public partial class WireMockServer
{
var name = string.Equals(HttpRequestMethod.DELETE, requestMessage.Method, StringComparison.OrdinalIgnoreCase) ?
requestMessage.Path.Substring(_adminPaths!.Scenarios.Length + 1) :
requestMessage.Path.Split('/').Reverse().Skip(1).First();
Enumerable.Reverse(requestMessage.Path.Split('/')).Skip(1).First();
return ResetScenario(name) ?
ResponseMessageBuilder.Create(200, "Scenario reset") :
@@ -711,7 +709,7 @@ public partial class WireMockServer
private IResponseMessage ScenariosSetState(HttpContext _, IRequestMessage requestMessage)
{
var name = requestMessage.Path.Split('/').Reverse().Skip(1).First();
var name = Enumerable.Reverse(requestMessage.Path.Split('/')).Skip(1).First();
if (!_options.Scenarios.ContainsKey(name))
{
ResponseMessageBuilder.Create(HttpStatusCode.NotFound, $"No scenario found by name '{name}'.");

View File

@@ -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.
/// </summary>
/// <param name="requestMessage">The request message.</param>
private IResponseMessage FileHead(HttpContext _, IRequestMessage requestMessage)
{
var filename = GetFileNameFromRequestMessage(requestMessage);

View File

@@ -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
{
/// <summary>
/// Get all active WebSocket connections
/// </summary>
[PublicAPI]
public IReadOnlyCollection<WireMockWebSocketContext> GetWebSocketConnections()
{
return _options.WebSocketRegistries.Values
.SelectMany(r => r.GetConnections())
.ToList();
}
/// <summary>
/// Get WebSocket connections for a specific mapping
/// </summary>
[PublicAPI]
public IReadOnlyCollection<WireMockWebSocketContext> GetWebSocketConnections(Guid mappingGuid)
{
return _options.WebSocketRegistries.TryGetValue(mappingGuid, out var registry) ? registry.GetConnections() : [];
}
/// <summary>
/// Close a specific WebSocket connection
/// </summary>
[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;
}
}
}
/// <summary>
/// Broadcast a message to all WebSocket connections in a specific mapping
/// </summary>
[PublicAPI]
public async Task BroadcastToWebSocketsAsync(Guid mappingGuid, string text)
{
if (_options.WebSocketRegistries.TryGetValue(mappingGuid, out var registry))
{
await registry.BroadcastTextAsync(text);
}
}
/// <summary>
/// Broadcast a message to all WebSocket connections
/// </summary>
[PublicAPI]
public async Task BroadcastToAllWebSocketsAsync(string text)
{
foreach (var registry in _options.WebSocketRegistries.Values)
{
await registry.BroadcastTextAsync(text);
}
}
}

View File

@@ -39,7 +39,7 @@ public partial class WireMockServer : IWireMockServer
private const int ServerStartDelayInMs = 100;
private readonly WireMockServerSettings _settings;
private readonly IOwinSelfHost? _httpServer;
private readonly AspNetCoreSelfHost? _httpServer;
private readonly IWireMockMiddlewareOptions _options = new WireMockMiddlewareOptions();
private readonly MappingConverter _mappingConverter;
private readonly MatcherMapper _matcherMapper;
@@ -537,15 +537,11 @@ public partial class WireMockServer : IWireMockServer
Guard.NotNull(tenant);
Guard.NotNull(audience);
//#if NETSTANDARD1_3
// throw new NotSupportedException("AzureADAuthentication is not supported for NETStandard 1.3");
//#else
_options.AuthenticationMatcher = new AzureADAuthenticationMatcher(
new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(),
new Microsoft.IdentityModel.Protocols.ConfigurationManager<Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfiguration>($"https://login.microsoftonline.com/{tenant}/.well-known/openid-configuration", new Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetriever()),
tenant,
audience);
//#endif
}
/// <inheritdoc cref="IWireMockServer.SetBasicAuthentication(string, string)" />

View File

@@ -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),
};
}
}
}

View File

@@ -1,7 +1,5 @@
// Copyright © WireMock.Net
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net;
@@ -16,7 +14,7 @@ namespace WireMock.Util;
/// </summary>
internal static class PortUtils
{
private static readonly Regex UrlDetailsRegex = new(@"^((?<proto>\w+)://)(?<host>[^/]+?):(?<port>\d+)\/?$", RegexOptions.Compiled, RegexConstants.DefaultTimeout);
private static readonly Regex UrlDetailsRegex = new(@"^((?<scheme>\w+)://)(?<host>[^/]+?):(?<port>\d+)\/?$", RegexOptions.Compiled, RegexConstants.DefaultTimeout);
/// <summary>
/// Finds a random, free port to be listened on.
@@ -37,9 +35,7 @@ internal static class PortUtils
}
finally
{
//#if !NETSTANDARD1_3
portSocket.Close();
//#endif
portSocket.Dispose();
}
}
@@ -75,9 +71,7 @@ internal static class PortUtils
{
foreach (var socket in sockets)
{
//#if !NETSTANDARD1_3
socket.Close();
//#endif
socket.Dispose();
}
}
@@ -97,8 +91,8 @@ internal static class PortUtils
var match = UrlDetailsRegex.Match(url);
if (match.Success)
{
scheme = match.Groups["proto"].Value;
isHttps = scheme.StartsWith("https", StringComparison.OrdinalIgnoreCase) || scheme.StartsWith("grpcs", StringComparison.OrdinalIgnoreCase);
scheme = match.Groups["scheme"].Value;
isHttps = scheme.StartsWith("https", StringComparison.OrdinalIgnoreCase) || scheme.StartsWith("grpcs", StringComparison.OrdinalIgnoreCase) || scheme.StartsWith("wss", StringComparison.OrdinalIgnoreCase);
isHttp2 = scheme.StartsWith("grpc", StringComparison.OrdinalIgnoreCase);
host = match.Groups["host"].Value;

View File

@@ -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<WebProxySettingsModel, WebProxySettings>();
TinyMapper.Bind<WebProxyModel, WebProxySettings>();
TinyMapper.Bind<ProxyUrlReplaceSettingsModel, ProxyUrlReplaceSettings>();
TinyMapper.Bind<WebSocketSettings, WebSocketSettingsModel>();
TinyMapper.Bind<WebSocketSettingsModel, WebSocketSettings>();
}
public ProxyAndRecordSettingsModel? Map(ProxyAndRecordSettings? instance)
@@ -53,4 +58,14 @@ internal sealed class TinyMapperUtils
{
return model == null ? null : TinyMapper.Map<WebProxySettings>(model);
}
public WebSocketSettingsModel? Map(WebSocketSettings? instance)
{
return instance == null ? null : TinyMapper.Map<WebSocketSettingsModel>(instance);
}
public WebSocketSettings? Map(WebSocketSettingsModel? model)
{
return model == null ? null : TinyMapper.Map<WebSocketSettings>(model);
}
}

View File

@@ -0,0 +1,291 @@
// Copyright © WireMock.Net
using System;
using System.Net.WebSockets;
using Stef.Validation;
using WireMock.Matchers;
using WireMock.ResponseBuilders;
using WireMock.Settings;
using WireMock.Transformers;
namespace WireMock.WebSockets;
internal class WebSocketBuilder(Response response) : IWebSocketBuilder
{
private readonly List<(IMatcher matcher, List<WebSocketMessageBuilder> messages)> _conditionalMessages = [];
/// <inheritdoc />
public string? AcceptProtocol { get; private set; }
/// <inheritdoc />
public bool IsEcho { get; private set; }
/// <inheritdoc />
public bool IsBroadcast { get; private set; }
/// <inheritdoc />
public Func<WebSocketMessage, IWebSocketContext, Task>? MessageHandler { get; private set; }
/// <inheritdoc />
public ProxyAndRecordSettings? ProxySettings { get; private set; }
/// <inheritdoc />
public TimeSpan? CloseTimeout { get; private set; }
/// <inheritdoc />
public int? MaxMessageSize { get; private set; }
/// <inheritdoc />
public int? ReceiveBufferSize { get; private set; }
/// <inheritdoc />
public TimeSpan? KeepAliveIntervalSeconds { get; private set; }
/// <inheritdoc />
public IWebSocketBuilder WithAcceptProtocol(string protocol)
{
AcceptProtocol = Guard.NotNull(protocol);
return this;
}
public IWebSocketBuilder WithEcho()
{
IsEcho = true;
return this;
}
public IWebSocketBuilder SendMessage(Action<IWebSocketMessageBuilder> configure)
{
Guard.NotNull(configure);
var messageBuilder = new WebSocketMessageBuilder();
configure(messageBuilder);
return WithMessageHandler(async (message, context) =>
{
if (messageBuilder.Delay.HasValue)
{
await Task.Delay(messageBuilder.Delay.Value);
}
await SendMessageAsync(context, messageBuilder, message);
});
}
public IWebSocketBuilder SendMessages(Action<IWebSocketMessagesBuilder> configure)
{
Guard.NotNull(configure);
var messagesBuilder = new WebSocketMessagesBuilder();
configure(messagesBuilder);
return WithMessageHandler(async (message, context) =>
{
foreach (var messageBuilder in messagesBuilder.Messages)
{
if (messageBuilder.Delay.HasValue)
{
await Task.Delay(messageBuilder.Delay.Value);
}
await SendMessageAsync(context, messageBuilder, message);
}
});
}
public IWebSocketMessageConditionBuilder WhenMessage(string wildcardPattern)
{
Guard.NotNull(wildcardPattern);
var matcher = new WildcardMatcher(MatchBehaviour.AcceptOnMatch, wildcardPattern);
return new WebSocketMessageConditionBuilder(this, matcher);
}
public IWebSocketMessageConditionBuilder WhenMessage(byte[] exactPattern)
{
Guard.NotNull(exactPattern);
var matcher = new ExactObjectMatcher(MatchBehaviour.AcceptOnMatch, exactPattern);
return new WebSocketMessageConditionBuilder(this, matcher);
}
public IWebSocketMessageConditionBuilder WhenMessage(IMatcher matcher)
{
Guard.NotNull(matcher);
return new WebSocketMessageConditionBuilder(this, matcher);
}
public IWebSocketBuilder WithMessageHandler(Func<WebSocketMessage, IWebSocketContext, Task> handler)
{
MessageHandler = Guard.NotNull(handler);
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;
}
internal IWebSocketBuilder AddConditionalMessage(IMatcher matcher, WebSocketMessageBuilder messageBuilder)
{
_conditionalMessages.Add((matcher, new List<WebSocketMessageBuilder> { messageBuilder }));
SetupConditionalHandler();
return this;
}
internal IWebSocketBuilder AddConditionalMessages(IMatcher matcher, List<WebSocketMessageBuilder> messages)
{
_conditionalMessages.Add((matcher, messages));
SetupConditionalHandler();
return this;
}
private void SetupConditionalHandler()
{
if (_conditionalMessages.Count == 0)
{
return;
}
WithMessageHandler(async (message, context) =>
{
// Check each condition in order
foreach (var (matcher, messages) in _conditionalMessages)
{
// Try to match the message
if (await MatchMessageAsync(message, matcher))
{
// Execute the corresponding messages
foreach (var messageBuilder in messages)
{
if (messageBuilder.Delay.HasValue)
{
await Task.Delay(messageBuilder.Delay.Value);
}
await SendMessageAsync(context, messageBuilder, message);
// If this message should close the connection, do it after sending
if (messageBuilder.ShouldClose)
{
try
{
await Task.Delay(100); // Small delay to ensure message is sent
await context.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closed by handler");
}
catch
{
// Ignore errors during close
}
}
}
return; // Stop after first match
}
}
});
}
private async Task SendMessageAsync(IWebSocketContext context, WebSocketMessageBuilder messageBuilder, WebSocketMessage incomingMessage)
{
switch (messageBuilder.Type)
{
case WebSocketMessageType.Text:
var text = messageBuilder.MessageText!;
if (response.UseTransformer)
{
text = ApplyTransformer(context, incomingMessage, text);
}
await context.SendAsync(text);
break;
case WebSocketMessageType.Binary:
await context.SendAsync(messageBuilder.MessageBytes!);
break;
}
}
private string ApplyTransformer(IWebSocketContext context, WebSocketMessage incomingMessage, string text)
{
try
{
if (incomingMessage == null)
{
// No incoming message, can't apply transformer
return text;
}
var transformer = TransformerFactory.Create(response.TransformerType, context.Mapping.Settings);
var model = new WebSocketTransformModel
{
Mapping = context.Mapping,
Request = context.RequestMessage,
Message = incomingMessage,
Data = incomingMessage.MessageType == WebSocketMessageType.Text ? incomingMessage.Text : null
};
return transformer.Transform(text, model);
}
catch
{
// If transformation fails, return original text
return text;
}
}
private static async Task<bool> MatchMessageAsync(WebSocketMessage message, IMatcher matcher)
{
if (message.MessageType == WebSocketMessageType.Text)
{
if (matcher is IStringMatcher stringMatcher)
{
var result = stringMatcher.IsMatch(message.Text);
return result.IsPerfect();
}
if (matcher is IFuncMatcher funcMatcher)
{
var result = funcMatcher.IsMatch(message.Text);
return result.IsPerfect();
}
}
if (message.MessageType == WebSocketMessageType.Binary && matcher is IBytesMatcher bytesMatcher && message.Bytes != null)
{
var result = await bytesMatcher.IsMatchAsync(message.Bytes);
return result.IsPerfect();
}
return false;
}
}

View File

@@ -0,0 +1,60 @@
// Copyright © WireMock.Net
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net.WebSockets;
namespace WireMock.WebSockets;
/// <summary>
/// Registry for managing WebSocket connections per mapping
/// </summary>
internal class WebSocketConnectionRegistry
{
private readonly ConcurrentDictionary<Guid, WireMockWebSocketContext> _connections = new();
/// <summary>
/// Add a connection to the registry
/// </summary>
public void AddConnection(WireMockWebSocketContext context)
{
_connections.TryAdd(context.ConnectionId, context);
}
/// <summary>
/// Remove a connection from the registry
/// </summary>
public void RemoveConnection(Guid connectionId)
{
_connections.TryRemove(connectionId, out _);
}
/// <summary>
/// Get all connections
/// </summary>
public IReadOnlyCollection<WireMockWebSocketContext> GetConnections()
{
return _connections.Values.ToList();
}
/// <summary>
/// Try to get a specific connection
/// </summary>
public bool TryGetConnection(Guid connectionId, [NotNullWhen(true)] out WireMockWebSocketContext? connection)
{
return _connections.TryGetValue(connectionId, out connection);
}
/// <summary>
/// Broadcast text to all connections
/// </summary>
public async Task BroadcastTextAsync(string text, CancellationToken cancellationToken = default)
{
var tasks = _connections.Values
.Where(c => c.WebSocket.State == WebSocketState.Open)
.Select(c => c.SendAsync(text, cancellationToken));
await Task.WhenAll(tasks);
}
}

View File

@@ -0,0 +1,56 @@
// Copyright © WireMock.Net
using System.Net.WebSockets;
using Stef.Validation;
namespace WireMock.WebSockets;
internal class WebSocketMessageBuilder : IWebSocketMessageBuilder
{
public string? MessageText { get; private set; }
public byte[]? MessageBytes { get; private set; }
public object? MessageData { get; private set; }
public TimeSpan? Delay { get; private set; }
public WebSocketMessageType Type { get; private set; }
public bool ShouldClose { get; private set; }
public IWebSocketMessageBuilder WithText(string text)
{
MessageText = Guard.NotNull(text);
Type = WebSocketMessageType.Text;
return this;
}
public IWebSocketMessageBuilder WithBinary(byte[] bytes)
{
MessageBytes = Guard.NotNull(bytes);
Type = WebSocketMessageType.Binary;
return this;
}
public IWebSocketMessageBuilder WithDelay(TimeSpan delay)
{
Delay = delay;
return this;
}
public IWebSocketMessageBuilder WithDelay(int delayInMilliseconds)
{
Guard.Condition(delayInMilliseconds, d => d >= 0, nameof(delayInMilliseconds));
Delay = TimeSpan.FromMilliseconds(delayInMilliseconds);
return this;
}
public IWebSocketMessageBuilder Close()
{
ShouldClose = true;
return this;
}
public IWebSocketMessageBuilder AndClose() => Close();
}

View File

@@ -0,0 +1,36 @@
// Copyright © WireMock.Net
using WireMock.Matchers;
using Stef.Validation;
namespace WireMock.WebSockets;
internal class WebSocketMessageConditionBuilder : IWebSocketMessageConditionBuilder
{
private readonly WebSocketBuilder _parent;
private readonly IMatcher _matcher;
public WebSocketMessageConditionBuilder(WebSocketBuilder parent, IMatcher matcher)
{
_parent = Guard.NotNull(parent);
_matcher = Guard.NotNull(matcher);
}
public IWebSocketBuilder SendMessage(Action<IWebSocketMessageBuilder> configure)
{
Guard.NotNull(configure);
var messageBuilder = new WebSocketMessageBuilder();
configure(messageBuilder);
return _parent.AddConditionalMessage(_matcher, messageBuilder);
}
public IWebSocketBuilder SendMessages(Action<IWebSocketMessagesBuilder> configure)
{
Guard.NotNull(configure);
var messagesBuilder = new WebSocketMessagesBuilder();
configure(messagesBuilder);
return _parent.AddConditionalMessages(_matcher, messagesBuilder.Messages);
}
}

View File

@@ -0,0 +1,16 @@
// Copyright © WireMock.Net
namespace WireMock.WebSockets;
internal class WebSocketMessagesBuilder : IWebSocketMessagesBuilder
{
internal List<WebSocketMessageBuilder> Messages { get; } = [];
public IWebSocketMessagesBuilder AddMessage(Action<IWebSocketMessageBuilder> configure)
{
var messageBuilder = new WebSocketMessageBuilder();
configure(messageBuilder);
Messages.Add(messageBuilder);
return this;
}
}

View File

@@ -0,0 +1,29 @@
// Copyright © WireMock.Net
namespace WireMock.WebSockets;
/// <summary>
/// Model for WebSocket message transformation
/// </summary>
internal struct WebSocketTransformModel
{
/// <summary>
/// The mapping that matched this WebSocket request
/// </summary>
public IMapping Mapping { get; set; }
/// <summary>
/// The original request that initiated the WebSocket connection
/// </summary>
public IRequestMessage Request { get; set; }
/// <summary>
/// The incoming WebSocket message
/// </summary>
public WebSocketMessage Message { get; set; }
/// <summary>
/// The message data as string
/// </summary>
public string? Data { get; set; }
}

View File

@@ -0,0 +1,178 @@
// Copyright © WireMock.Net
using System.Net.WebSockets;
using System.Text;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
using Stef.Validation;
using WireMock.Extensions;
using WireMock.Owin;
namespace WireMock.WebSockets;
/// <summary>
/// WebSocket context implementation
/// </summary>
public class WireMockWebSocketContext : IWebSocketContext
{
private readonly IWireMockMiddlewareOptions _options;
/// <inheritdoc />
public Guid ConnectionId { get; } = Guid.NewGuid();
/// <inheritdoc />
public HttpContext HttpContext { get; }
/// <inheritdoc />
public WebSocket WebSocket { get; }
/// <inheritdoc />
public IRequestMessage RequestMessage { get; }
/// <inheritdoc />
public IMapping Mapping { get; }
internal WebSocketConnectionRegistry? Registry { get; }
internal WebSocketBuilder Builder { get; }
/// <summary>
/// Creates a new WebSocketContext
/// </summary>
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<IWireMockMiddlewareOptions>(nameof(WireMockMiddlewareOptions), out var options))
{
_options = options;
}
else
{
throw new InvalidOperationException("WireMockMiddlewareOptions not found in HttpContext.Items");
}
}
/// <inheritdoc />
public Task SendAsync(string text, CancellationToken cancellationToken = default)
{
var bytes = Encoding.UTF8.GetBytes(text);
return WebSocket.SendAsync(
new ArraySegment<byte>(bytes),
WebSocketMessageType.Text,
true,
cancellationToken
);
}
/// <inheritdoc />
public Task SendAsync(byte[] bytes, CancellationToken cancellationToken = default)
{
return WebSocket.SendAsync(
new ArraySegment<byte>(bytes),
WebSocketMessageType.Binary,
true,
cancellationToken
);
}
/// <inheritdoc />
public Task CloseAsync(WebSocketCloseStatus closeStatus, string statusDescription)
{
return WebSocket.CloseAsync(closeStatus, statusDescription, CancellationToken.None);
}
/// <inheritdoc />
public void SetScenarioState(string nextState)
{
SetScenarioState(nextState, null);
}
/// <inheritdoc />
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
});
}
}
/// <summary>
/// Update scenario state following the same pattern as WireMockMiddleware.UpdateScenarioState
/// This is called automatically when the WebSocket connection is established.
/// </summary>
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;
}
/// <inheritdoc />
public async Task BroadcastTextAsync(string text, CancellationToken cancellationToken = default)
{
if (Registry != null)
{
await Registry.BroadcastTextAsync(text, cancellationToken);
}
}
}

View File

@@ -3,8 +3,7 @@
<Description>Minimal version from the lightweight Http Mocking Server for .NET</Description>
<AssemblyTitle>WireMock.Net.Minimal</AssemblyTitle>
<Authors>Stef Heyenrath</Authors>
<!--<TargetFrameworks>net451;net452;net46;net461;netstandard1.3;netstandard2.0;netstandard2.1;netcoreapp3.1;net5.0;net6.0;net7.0;net8.0</TargetFrameworks>-->
<TargetFrameworks>net48;net8.0</TargetFrameworks>
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<AssemblyName>WireMock.Net.Minimal</AssemblyName>
<PackageId>WireMock.Net.Minimal</PackageId>
@@ -38,10 +37,11 @@
</ItemGroup>
<ItemGroup>
<!--<PackageReference Include="JmesPath.Net.SourceOnly" Version="1.0.330-20260213.1" />-->
<PackageReference Include="JmesPath.Net" Version="1.0.330" />
<PackageReference Include="NJsonSchema.Extensions" Version="0.1.0" />
<PackageReference Include="NSwag.Core" Version="13.16.1" />
<PackageReference Include="SimMetrics.Net" Version="1.0.5" />
<PackageReference Include="JmesPath.Net" Version="1.0.330" />
<PackageReference Include="TinyMapper.Signed" Version="4.0.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="6.34.0" />
<PackageReference Include="Scriban.Signed" Version="5.5.0" />
@@ -51,11 +51,23 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'net48'">
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.9" />
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.3.9" />
</ItemGroup>
<PropertyGroup>
<!-- Suppress warnings from JmesPath source-only package -->
<!--<NoWarn>$(NoWarn);CS8600;CS8602;CS8603;CS8604;CS8619;CS8625;CS0649</NoWarn>-->
</PropertyGroup>
<!--<ItemGroup>
--><!-- Disable all warnings for JmesPath source-only package files --><!--
<Compile Update="**\jmespath.net.sourceonly\**\*.cs">
<NoWarn>$(NoWarn);CS0001-CS9999</NoWarn>
</Compile>
</ItemGroup>-->
<ItemGroup>
<Compile Update="Server\WireMockServer.*.cs">
<DependentUpon>WireMockServer.cs</DependentUpon>