This commit is contained in:
Stef Heyenrath
2026-02-16 09:06:27 +01:00
parent 452dd2a529
commit bc15cfcefd
20 changed files with 799 additions and 215 deletions

View File

@@ -1,7 +1,5 @@
// Copyright © WireMock.Net
using System;
namespace WireMock.Admin.Requests;
/// <summary>
@@ -17,12 +15,12 @@ public class LogEntryModel
/// <summary>
/// The request.
/// </summary>
public required LogRequestModel Request { get; init; }
public LogRequestModel? Request { get; init; }
/// <summary>
/// The response.
/// </summary>
public required LogResponseModel Response { get; init; }
public LogResponseModel? Response { get; init; }
/// <summary>
/// The mapping unique identifier.

View File

@@ -1,7 +1,5 @@
// Copyright © WireMock.Net
using System;
using System.Collections.Generic;
using WireMock.Admin.Mappings;
using WireMock.Types;

View File

@@ -64,12 +64,12 @@ public class LogResponseModel
/// <summary>
/// The detected body type (detection based on body content).
/// </summary>
public BodyType? DetectedBodyType { get; set; }
public string? DetectedBodyType { get; set; }
/// <summary>
/// The detected body type (detection based on Content-Type).
/// </summary>
public BodyType? DetectedBodyTypeFromContentType { get; set; }
public string? DetectedBodyTypeFromContentType { get; set; }
/// <summary>
/// The FaultType.

View File

@@ -1,7 +1,5 @@
// Copyright © WireMock.Net
using System;
using System.Collections.Generic;
using System.Security.Cryptography.X509Certificates;
using WireMock.Types;
using WireMock.Util;

View File

@@ -1,6 +1,5 @@
// Copyright © WireMock.Net
using System.Collections.Generic;
using WireMock.ResponseBuilders;
using WireMock.Types;
using WireMock.Util;
@@ -52,6 +51,11 @@ public interface IResponseMessage
/// </summary>
object? StatusCode { get; set; }
/// <summary>
/// Gets the DateTime.
/// </summary>
DateTime DateTime { get; }
/// <summary>
/// Adds the header.
/// </summary>

View File

@@ -38,20 +38,20 @@ public interface ILogEntry
/// <summary>
/// Gets the partial match result.
/// </summary>
IRequestMatchResult PartialMatchResult { get; }
IRequestMatchResult? PartialMatchResult { get; }
/// <summary>
/// Gets the request match result.
/// </summary>
IRequestMatchResult RequestMatchResult { get; }
IRequestMatchResult? RequestMatchResult { get; }
/// <summary>
/// Gets the request message.
/// </summary>
IRequestMessage RequestMessage { get; }
IRequestMessage? RequestMessage { get; }
/// <summary>
/// Gets the response message.
/// </summary>
IResponseMessage ResponseMessage { get; }
IResponseMessage? ResponseMessage { get; }
}

View File

@@ -1,6 +1,5 @@
// Copyright © WireMock.Net
using System;
using WireMock.Matchers.Request;
namespace WireMock.Logging;
@@ -14,13 +13,13 @@ public class LogEntry : ILogEntry
public Guid Guid { get; set; }
/// <inheritdoc cref="ILogEntry.RequestMessage" />
public IRequestMessage RequestMessage { get; set; } = null!;
public IRequestMessage? RequestMessage { get; set; }
/// <inheritdoc cref="ILogEntry.ResponseMessage" />
public IResponseMessage ResponseMessage { get; set; } = null!;
public IResponseMessage? ResponseMessage { get; set; }
/// <inheritdoc cref="ILogEntry.RequestMatchResult" />
public IRequestMatchResult RequestMatchResult { get; set; } = null!;
public IRequestMatchResult? RequestMatchResult { get; set; }
/// <inheritdoc cref="ILogEntry.MappingGuid" />
public Guid? MappingGuid { get; set; }
@@ -35,5 +34,5 @@ public class LogEntry : ILogEntry
public string? PartialMappingTitle { get; set; }
/// <inheritdoc cref="ILogEntry.PartialMatchResult" />
public IRequestMatchResult PartialMatchResult { get; set; } = null!;
public IRequestMatchResult? PartialMatchResult { get; set; }
}

View File

@@ -1,8 +1,10 @@
// Copyright © WireMock.Net
using System.Diagnostics;
using System.Net.WebSockets;
using WireMock.Logging;
using WireMock.Settings;
using WireMock.WebSockets;
namespace WireMock.Owin.ActivityTracing;
@@ -195,4 +197,59 @@ internal static class WireMockActivitySource
activity.SetTag("exception.message", exception.Message);
activity.SetTag("exception.stacktrace", exception.ToString());
}
/// <summary>
/// Starts a new activity for a WebSocket message.
/// </summary>
/// <param name="direction">The direction of the message.</param>
/// <param name="mappingGuid">The GUID of the mapping handling the WebSocket.</param>
/// <returns>The started activity, or null if tracing is not enabled.</returns>
internal static Activity? StartWebSocketMessageActivity(WebSocketMessageDirection direction, Guid mappingGuid)
{
if (!Source.HasListeners())
{
return null;
}
var activity = Source.StartActivity(
$"WireMock WebSocket {direction.ToString().ToLowerInvariant()}",
ActivityKind.Server
);
if (activity != null)
{
activity.SetTag(WireMockSemanticConventions.MappingGuid, mappingGuid.ToString());
}
return activity;
}
/// <summary>
/// Enriches an activity with WebSocket message information.
/// </summary>
internal static void EnrichWithWebSocketMessage(
Activity? activity,
WebSocketMessageType messageType,
int messageSize,
bool endOfMessage,
string? textContent = null,
ActivityTracingOptions? options = null)
{
if (activity == null)
{
return;
}
activity.SetTag(WireMockSemanticConventions.WebSocketMessageType, messageType.ToString());
activity.SetTag(WireMockSemanticConventions.WebSocketMessageSize, messageSize);
activity.SetTag(WireMockSemanticConventions.WebSocketEndOfMessage, endOfMessage);
// Record message content if enabled and it's text
if (options?.RecordRequestBody == true && messageType == WebSocketMessageType.Text && textContent != null)
{
activity.SetTag(WireMockSemanticConventions.WebSocketMessageContent, textContent);
}
activity.SetTag("otel.status_code", "OK");
}
}

View File

@@ -25,4 +25,10 @@ internal static class WireMockSemanticConventions
public const string RequestGuid = "wiremock.request.guid";
public const string RequestBody = "wiremock.request.body";
public const string ResponseBody = "wiremock.response.body";
// WebSocket-specific attributes
public const string WebSocketMessageType = "wiremock.websocket.message.type";
public const string WebSocketMessageSize = "wiremock.websocket.message.size";
public const string WebSocketEndOfMessage = "wiremock.websocket.message.end_of_message";
public const string WebSocketMessageContent = "wiremock.websocket.message.content";
}

View File

@@ -1,10 +1,13 @@
// Copyright © WireMock.Net
using System.Diagnostics;
using WireMock.Logging;
namespace WireMock.Owin;
internal interface IWireMockMiddlewareLogger
{
void Log(bool logRequest, RequestMessage request, IResponseMessage? response, MappingMatcherResult? match, MappingMatcherResult? partialMatch, Activity? activity);
void LogRequestAndResponse(bool logRequest, RequestMessage request, IResponseMessage? response, MappingMatcherResult? match, MappingMatcherResult? partialMatch, Activity? activity);
void LogLogEntry(LogEntry entry, bool addRequest);
}

View File

@@ -42,7 +42,8 @@ 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;
ctx.Items[nameof(IWireMockMiddlewareOptions)] = options;
ctx.Items[nameof(IWireMockMiddlewareLogger)] = logger;
var request = await requestMapper.MapAsync(ctx, options).ConfigureAwait(false);
@@ -158,7 +159,7 @@ internal class WireMockMiddleware(
}
finally
{
logger.Log(logRequest, request, response, result.Match, result.Partial, activity);
logger.LogRequestAndResponse(logRequest, request, response, result.Match, result.Partial, activity);
try
{

View File

@@ -2,43 +2,46 @@
using System.Diagnostics;
using WireMock.Logging;
using WireMock.Matchers.Request;
using WireMock.Owin.ActivityTracing;
using WireMock.Serialization;
using WireMock.Util;
namespace WireMock.Owin;
internal class WireMockMiddlewareLogger(IWireMockMiddlewareOptions _options, LogEntryMapper _logEntryMapper, IGuidUtils _guidUtils) : IWireMockMiddlewareLogger
internal class WireMockMiddlewareLogger(
IWireMockMiddlewareOptions _options,
LogEntryMapper _logEntryMapper,
IGuidUtils _guidUtils
) : IWireMockMiddlewareLogger
{
public void Log(bool logRequest, RequestMessage request, IResponseMessage? response, MappingMatcherResult? match, MappingMatcherResult? partialMatch, Activity? activity)
public void LogRequestAndResponse(bool logRequest, RequestMessage request, IResponseMessage? response, MappingMatcherResult? match, MappingMatcherResult? partialMatch, Activity? activity)
{
var log = new LogEntry
var logEntry = new LogEntry
{
Guid = _guidUtils.NewGuid(),
RequestMessage = request,
ResponseMessage = response ?? new ResponseMessage(),
ResponseMessage = response,
MappingGuid = match?.Mapping?.Guid,
MappingTitle = match?.Mapping?.Title,
RequestMatchResult = match?.RequestMatchResult ?? new RequestMatchResult(),
RequestMatchResult = match?.RequestMatchResult,
PartialMappingGuid = partialMatch?.Mapping?.Guid,
PartialMappingTitle = partialMatch?.Mapping?.Title,
PartialMatchResult = partialMatch?.RequestMatchResult ?? new RequestMatchResult()
PartialMatchResult = partialMatch?.RequestMatchResult
};
WireMockActivitySource.EnrichWithLogEntry(activity, log, _options.ActivityTracingOptions);
WireMockActivitySource.EnrichWithLogEntry(activity, logEntry, _options.ActivityTracingOptions);
activity?.Dispose();
LogRequest(log, logRequest);
LogLogEntry(logEntry, logRequest);
try
{
if (_options.SaveUnmatchedRequests == true && match?.RequestMatchResult is not { IsPerfectMatch: true })
{
var filename = $"{log.Guid}.LogEntry.json";
_options.FileSystemHandler?.WriteUnmatchedRequest(filename, JsonUtils.Serialize(log));
var filename = $"{logEntry.Guid}.LogEntry.json";
_options.FileSystemHandler?.WriteUnmatchedRequest(filename, JsonUtils.Serialize(logEntry));
}
}
catch
@@ -47,21 +50,25 @@ internal class WireMockMiddlewareLogger(IWireMockMiddlewareOptions _options, Log
}
}
private void LogRequest(LogEntry entry, bool addRequest)
public void LogLogEntry(LogEntry entry, bool addRequest)
{
_options.Logger.DebugRequestResponse(_logEntryMapper.Map(entry), entry.RequestMessage.Path.StartsWith("/__admin/"));
// If addRequest is set to true and MaxRequestLogCount is null or does have a value greater than 0, try to add a new request log.
if (addRequest && _options.MaxRequestLogCount is null or > 0)
if (entry.RequestMessage != null)
{
TryAddLogEntry(entry);
_options.Logger.DebugRequestResponse(_logEntryMapper.Map(entry), entry.RequestMessage.Path.StartsWith("/__admin/"));
// If addRequest is set to true and MaxRequestLogCount is null or does have a value greater than 0, try to add a new request log.
if (addRequest && _options.MaxRequestLogCount is null or > 0)
{
TryAddLogEntry(entry);
}
}
// In case MaxRequestLogCount has a value greater than 0, try to delete existing request logs based on the count.
if (_options.MaxRequestLogCount is > 0)
{
var logEntries = _options.LogEntries.ToList();
foreach (var logEntry in logEntries.OrderBy(le => le.RequestMessage.DateTime).Take(logEntries.Count - _options.MaxRequestLogCount.Value))
var logEntries = _options.LogEntries.Where(le => le.RequestMessage != null).ToList();
foreach (var logEntry in logEntries.OrderBy(le => le.RequestMessage!.DateTime).Take(logEntries.Count - _options.MaxRequestLogCount.Value))
{
TryRemoveLogEntry(logEntry);
}
@@ -70,8 +77,10 @@ internal class WireMockMiddlewareLogger(IWireMockMiddlewareOptions _options, Log
// In case RequestLogExpirationDuration has a value greater than 0, try to delete existing request logs based on the date.
if (_options.RequestLogExpirationDuration is > 0)
{
var logEntries = _options.LogEntries.Where(le => le.RequestMessage != null).ToList();
var checkTime = DateTime.UtcNow.AddHours(-_options.RequestLogExpirationDuration.Value);
foreach (var logEntry in _options.LogEntries.ToList().Where(le => le.RequestMessage.DateTime < checkTime))
foreach (var logEntry in logEntries.Where(le => le.RequestMessage!.DateTime < checkTime))
{
TryRemoveLogEntry(logEntry);
}

View File

@@ -2,13 +2,10 @@
// 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.Collections.Generic;
using System.Linq;
using Stef.Validation;
using WireMock.ResponseBuilders;
using WireMock.Types;
using WireMock.Util;
using Stef.Validation;
using WireMock.WebSockets;
namespace WireMock;
@@ -41,6 +38,9 @@ public class ResponseMessage : IResponseMessage
/// <inheritdoc cref="IResponseMessage.FaultPercentage" />
public double? FaultPercentage { get; set; }
/// <inheritdoc />
public DateTime DateTime { get; set; }
/// <inheritdoc />
public void AddHeader(string name, string value)
{

View File

@@ -1,13 +1,22 @@
// Copyright © WireMock.Net
using System.Buffers;
using System.Diagnostics;
using System.Drawing;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using Microsoft.AspNetCore.Http;
using WireMock.Constants;
using WireMock.Logging;
using WireMock.Matchers;
using WireMock.Matchers.Request;
using WireMock.Models;
using WireMock.Owin;
using WireMock.Owin.ActivityTracing;
using WireMock.Settings;
using WireMock.Types;
using WireMock.Util;
using WireMock.WebSockets;
namespace WireMock.ResponseProviders;
@@ -42,10 +51,17 @@ internal class WebSocketResponseProvider(WebSocketBuilder builder) : IResponsePr
#endif
// Get options from HttpContext.Items (set by WireMockMiddleware)
if (!context.Items.TryGetValue(nameof(WireMockMiddlewareOptions), out var optionsObj) ||
if (!context.Items.TryGetValue(nameof(IWireMockMiddlewareOptions), out var optionsObj) ||
optionsObj is not IWireMockMiddlewareOptions options)
{
throw new InvalidOperationException("WireMockMiddlewareOptions not found in HttpContext.Items");
throw new InvalidOperationException("IWireMockMiddlewareOptions not found in HttpContext.Items");
}
// Get logger from HttpContext.Items
if (!context.Items.TryGetValue(nameof(IWireMockMiddlewareLogger), out var loggerObj) ||
loggerObj is not IWireMockMiddlewareLogger logger)
{
throw new InvalidOperationException("IWireMockMiddlewareLogger not found in HttpContext.Items");
}
// Get or create registry from options
@@ -60,7 +76,9 @@ internal class WebSocketResponseProvider(WebSocketBuilder builder) : IResponsePr
requestMessage,
mapping,
registry,
builder
builder,
options,
logger
);
// Update scenario state following the same pattern as WireMockMiddleware
@@ -124,31 +142,87 @@ internal class WebSocketResponseProvider(WebSocketBuilder builder) : IResponsePr
var timeout = context.Builder.CloseTimeout ?? TimeSpan.FromMinutes(WebSocketConstants.DefaultCloseTimeoutMinutes);
using var cts = new CancellationTokenSource(timeout);
var shouldTrace = context.Options?.ActivityTracingOptions is not null;
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)
Activity? receiveActivity = null;
if (shouldTrace)
{
await context.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Closed by client"
).ConfigureAwait(false);
break;
receiveActivity = WireMockActivitySource.StartWebSocketMessageActivity(WebSocketMessageDirection.Receive, context.Mapping.Guid);
}
// Echo back
await context.WebSocket.SendAsync(
new ArraySegment<byte>(buffer, 0, result.Count),
result.MessageType,
result.EndOfMessage,
cts.Token
).ConfigureAwait(false);
try
{
var result = await context.WebSocket.ReceiveAsync(
new ArraySegment<byte>(buffer),
cts.Token
).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
if (shouldTrace)
{
WireMockActivitySource.EnrichWithWebSocketMessage(
receiveActivity,
result.MessageType,
result.Count,
result.EndOfMessage,
null,
context.Options?.ActivityTracingOptions
);
}
LogWebSocketMessage(context, WebSocketMessageDirection.Receive, result.MessageType, null, receiveActivity);
await context.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Closed by client"
).ConfigureAwait(false);
break;
}
// Enrich activity with message details
string? textContent = null;
if (result.MessageType == WebSocketMessageType.Text)
{
textContent = Encoding.UTF8.GetString(buffer, 0, result.Count);
}
if (shouldTrace)
{
WireMockActivitySource.EnrichWithWebSocketMessage(
receiveActivity,
result.MessageType,
result.Count,
result.EndOfMessage,
textContent,
context.Options?.ActivityTracingOptions
);
}
// Log the receive operation
LogWebSocketMessage(context, WebSocketMessageDirection.Receive, result.MessageType, textContent, receiveActivity);
// Echo back (this will be logged by context.SendAsync)
await context.WebSocket.SendAsync(
new ArraySegment<byte>(buffer, 0, result.Count),
result.MessageType,
result.EndOfMessage,
cts.Token
).ConfigureAwait(false);
}
catch (Exception ex)
{
WireMockActivitySource.RecordException(receiveActivity, ex);
throw;
}
finally
{
receiveActivity?.Dispose();
}
}
}
catch (OperationCanceledException)
@@ -169,28 +243,79 @@ internal class WebSocketResponseProvider(WebSocketBuilder builder) : IResponsePr
var timeout = context.Builder.CloseTimeout ?? TimeSpan.FromMinutes(WebSocketConstants.DefaultCloseTimeoutMinutes);
using var cts = new CancellationTokenSource(timeout);
var shouldTrace = context.Options?.ActivityTracingOptions is not null;
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)
Activity? receiveActivity = null;
if (shouldTrace)
{
await context.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Closed by client"
).ConfigureAwait(false);
break;
receiveActivity = WireMockActivitySource.StartWebSocketMessageActivity(WebSocketMessageDirection.Receive, context.Mapping.Guid);
}
var message = CreateWebSocketMessage(result, buffer);
try
{
var result = await context.WebSocket.ReceiveAsync(
new ArraySegment<byte>(buffer),
cts.Token
).ConfigureAwait(false);
// Call custom handler
await handler(message, context).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
if (shouldTrace)
{
WireMockActivitySource.EnrichWithWebSocketMessage(
receiveActivity,
result.MessageType,
result.Count,
result.EndOfMessage,
null,
context.Options?.ActivityTracingOptions
);
}
LogWebSocketMessage(context, WebSocketMessageDirection.Receive, result.MessageType, null, receiveActivity);
await context.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Closed by client"
).ConfigureAwait(false);
break;
}
var message = CreateWebSocketMessage(result, buffer);
// Enrich activity with message details
if (shouldTrace)
{
WireMockActivitySource.EnrichWithWebSocketMessage(
receiveActivity,
result.MessageType,
result.Count,
result.EndOfMessage,
message.Text,
context.Options?.ActivityTracingOptions
);
}
// Log the receive operation
object? data = message.Text != null ? message.Text : message.Bytes;
LogWebSocketMessage(context, WebSocketMessageDirection.Receive, result.MessageType, data, receiveActivity);
// Call custom handler
await handler(message, context).ConfigureAwait(false);
}
catch (Exception ex)
{
WireMockActivitySource.RecordException(receiveActivity, ex);
throw;
}
finally
{
receiveActivity?.Dispose();
}
}
}
catch (OperationCanceledException)
@@ -210,8 +335,8 @@ internal class WebSocketResponseProvider(WebSocketBuilder builder) : IResponsePr
await clientWebSocket.ConnectAsync(targetUri, CancellationToken.None).ConfigureAwait(false);
// Bidirectional proxy
var clientToServer = ForwardMessagesAsync(context.WebSocket, clientWebSocket);
var serverToClient = ForwardMessagesAsync(clientWebSocket, context.WebSocket);
var clientToServer = ForwardMessagesAsync(context, clientWebSocket, WebSocketMessageDirection.Receive);
var serverToClient = ForwardMessagesAsync(context, clientWebSocket, WebSocketMessageDirection.Send);
await Task.WhenAny(clientToServer, serverToClient).ConfigureAwait(false);
@@ -227,30 +352,103 @@ internal class WebSocketResponseProvider(WebSocketBuilder builder) : IResponsePr
}
}
private static async Task ForwardMessagesAsync(WebSocket source, WebSocket destination)
private static async Task ForwardMessagesAsync(
WireMockWebSocketContext context,
ClientWebSocket clientWebSocket,
WebSocketMessageDirection direction)
{
var buffer = new byte[WebSocketConstants.ProxyForwardBufferSize];
// Get options for activity tracing
var options = context.HttpContext.Items.TryGetValue(nameof(WireMockMiddlewareOptions), out var optionsObj) &&
optionsObj is IWireMockMiddlewareOptions wireMockOptions
? wireMockOptions
: null;
var shouldTrace = options?.ActivityTracingOptions is not null;
var source = direction == WebSocketMessageDirection.Receive ? context.WebSocket : (WebSocket)clientWebSocket;
var destination = direction == WebSocketMessageDirection.Receive ? (WebSocket)clientWebSocket : context.WebSocket;
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)
Activity? activity = null;
if (shouldTrace)
{
await destination.CloseAsync(
result.CloseStatus ?? WebSocketCloseStatus.NormalClosure,
result.CloseStatusDescription,
CancellationToken.None
);
break;
activity = WireMockActivitySource.StartWebSocketMessageActivity(direction, context.Mapping.Guid);
}
await destination.SendAsync(
new ArraySegment<byte>(buffer, 0, result.Count),
result.MessageType,
result.EndOfMessage,
CancellationToken.None
);
try
{
var result = await source.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
if (shouldTrace)
{
WireMockActivitySource.EnrichWithWebSocketMessage(
activity,
result.MessageType,
result.Count,
result.EndOfMessage,
null,
options?.ActivityTracingOptions
);
}
LogWebSocketMessage(context, direction, result.MessageType, null, activity);
await destination.CloseAsync(
result.CloseStatus ?? WebSocketCloseStatus.NormalClosure,
result.CloseStatusDescription,
CancellationToken.None
);
break;
}
// Enrich activity with message details
object? data = null;
if (result.MessageType == WebSocketMessageType.Text)
{
data = Encoding.UTF8.GetString(buffer, 0, result.Count);
}
else if (result.MessageType == WebSocketMessageType.Binary)
{
data = new byte[result.Count];
Array.Copy(buffer, (byte[])data, result.Count);
}
if (shouldTrace)
{
WireMockActivitySource.EnrichWithWebSocketMessage(
activity,
result.MessageType,
result.Count,
result.EndOfMessage,
data as string,
options?.ActivityTracingOptions
);
}
// Log the proxy operation
LogWebSocketMessage(context, direction, result.MessageType, data, activity);
await destination.SendAsync(
new ArraySegment<byte>(buffer, 0, result.Count),
result.MessageType,
result.EndOfMessage,
CancellationToken.None
);
}
catch (Exception ex)
{
WireMockActivitySource.RecordException(activity, ex);
throw;
}
finally
{
activity?.Dispose();
}
}
}
@@ -260,19 +458,65 @@ internal class WebSocketResponseProvider(WebSocketBuilder builder) : IResponsePr
var timeout = context.Builder.CloseTimeout ?? TimeSpan.FromMinutes(WebSocketConstants.DefaultCloseTimeoutMinutes);
using var cts = new CancellationTokenSource(timeout);
var shouldTrace = context.Options?.ActivityTracingOptions is not null;
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)
Activity? receiveActivity = null;
if (shouldTrace)
{
await context.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closed by client");
break;
receiveActivity = WireMockActivitySource.StartWebSocketMessageActivity(WebSocketMessageDirection.Receive, context.Mapping.Guid);
}
try
{
var result = await context.WebSocket.ReceiveAsync(
new ArraySegment<byte>(buffer),
cts.Token
);
if (shouldTrace)
{
WireMockActivitySource.EnrichWithWebSocketMessage(
receiveActivity,
result.MessageType,
result.Count,
result.EndOfMessage,
null,
context.Options?.ActivityTracingOptions
);
}
// Log the receive operation
object? data = null;
if (result.MessageType == WebSocketMessageType.Text)
{
data = Encoding.UTF8.GetString(buffer, 0, result.Count);
}
else if (result.MessageType == WebSocketMessageType.Binary)
{
data = new byte[result.Count];
Array.Copy(buffer, (byte[])data, result.Count);
}
LogWebSocketMessage(context, WebSocketMessageDirection.Receive, result.MessageType, data, receiveActivity);
if (result.MessageType == WebSocketMessageType.Close)
{
await context.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closed by client");
break;
}
}
catch (Exception ex)
{
WireMockActivitySource.RecordException(receiveActivity, ex);
throw;
}
finally
{
receiveActivity?.Dispose();
}
}
}
@@ -285,6 +529,105 @@ internal class WebSocketResponseProvider(WebSocketBuilder builder) : IResponsePr
}
}
private static void LogWebSocketMessage(
WireMockWebSocketContext context,
WebSocketMessageDirection direction,
WebSocketMessageType messageType,
object? data,
Activity? activity)
{
// Skip logging if log count limit is disabled
if (context.Options.MaxRequestLogCount == 0)
{
return;
}
// Create body data
IBodyData bodyData;
if (messageType == WebSocketMessageType.Text && data is string textContent)
{
bodyData = new BodyData
{
BodyAsString = textContent,
DetectedBodyType = BodyType.String
};
}
else if (messageType == WebSocketMessageType.Binary && data is byte[] binary)
{
bodyData = new BodyData
{
BodyAsBytes = binary,
DetectedBodyType = BodyType.Bytes
};
}
else
{
bodyData = new BodyData
{
BodyAsString = messageType.ToString(),
DetectedBodyType = BodyType.Bytes
};
}
// Create a pseudo-request or pseudo-response depending on direction
RequestMessage? requestMessage = null;
IResponseMessage? responseMessage = null;
var method = $"WS_{direction.ToString().ToUpperInvariant()}";
if (direction == WebSocketMessageDirection.Receive)
{
// Received message - log as request
requestMessage = new RequestMessage(
new UrlDetails(context.RequestMessage.Url),
method,
context.RequestMessage.ClientIP,
bodyData,
null,
null
)
{
DateTime = DateTime.UtcNow
};
}
else
{
// Sent message - log as response
responseMessage = new ResponseMessage
{
StatusCode = HttpStatusCode.SwitchingProtocols, // WebSocket status
BodyData = bodyData,
DateTime = DateTime.UtcNow
};
}
// Create a perfect match result
var requestMatchResult = new RequestMatchResult();
requestMatchResult.AddScore(typeof(WebSocketMessageDirection), MatchScores.Perfect, null);
// Create log entry
var logEntry = new LogEntry
{
Guid = Guid.NewGuid(),
RequestMessage = requestMessage,
ResponseMessage = responseMessage,
MappingGuid = context.Mapping.Guid,
MappingTitle = context.Mapping.Title,
RequestMatchResult = requestMatchResult
};
// Enrich activity if present
if (activity != null && context.Options.ActivityTracingOptions != null)
{
WireMockActivitySource.EnrichWithLogEntry(activity, logEntry, context.Options.ActivityTracingOptions);
}
// Log using LogLogEntry
context.Logger.LogLogEntry(logEntry, context.Options.MaxRequestLogCount is null or > 0);
activity?.Dispose();
}
private static WebSocketMessage CreateWebSocketMessage(WebSocketReceiveResult result, byte[] buffer)
{
var message = new WebSocketMessage

View File

@@ -1,6 +1,5 @@
// Copyright © WireMock.Net
using Stef.Validation;
using WireMock.Admin.Mappings;
using WireMock.Admin.Requests;
using WireMock.Logging;
@@ -11,95 +10,96 @@ using WireMock.Types;
namespace WireMock.Serialization;
internal class LogEntryMapper
internal class LogEntryMapper(IWireMockMiddlewareOptions options)
{
private readonly IWireMockMiddlewareOptions _options;
public LogEntryMapper(IWireMockMiddlewareOptions options)
{
_options = Guard.NotNull(options);
}
public LogEntryModel Map(ILogEntry logEntry)
{
var logRequestModel = new LogRequestModel
LogRequestModel? logRequestModel = null;
if (logEntry.RequestMessage != null)
{
DateTime = logEntry.RequestMessage.DateTime,
ClientIP = logEntry.RequestMessage.ClientIP,
Path = logEntry.RequestMessage.Path,
AbsolutePath = logEntry.RequestMessage.AbsolutePath,
Url = logEntry.RequestMessage.Url,
AbsoluteUrl = logEntry.RequestMessage.AbsoluteUrl,
ProxyUrl = logEntry.RequestMessage.ProxyUrl,
Query = logEntry.RequestMessage.Query,
Method = logEntry.RequestMessage.Method,
HttpVersion = logEntry.RequestMessage.HttpVersion,
Headers = logEntry.RequestMessage.Headers,
Cookies = logEntry.RequestMessage.Cookies
};
if (logEntry.RequestMessage.BodyData != null)
{
logRequestModel.DetectedBodyType = logEntry.RequestMessage.BodyData.DetectedBodyType?.ToString();
logRequestModel.DetectedBodyTypeFromContentType = logEntry.RequestMessage.BodyData.DetectedBodyTypeFromContentType?.ToString();
switch (logEntry.RequestMessage.BodyData.DetectedBodyType)
logRequestModel = new LogRequestModel
{
case BodyType.String:
case BodyType.FormUrlEncoded:
logRequestModel.Body = logEntry.RequestMessage.BodyData.BodyAsString;
break;
DateTime = logEntry.RequestMessage.DateTime,
ClientIP = logEntry.RequestMessage.ClientIP,
Path = logEntry.RequestMessage.Path,
AbsolutePath = logEntry.RequestMessage.AbsolutePath,
Url = logEntry.RequestMessage.Url,
AbsoluteUrl = logEntry.RequestMessage.AbsoluteUrl,
ProxyUrl = logEntry.RequestMessage.ProxyUrl,
Query = logEntry.RequestMessage.Query,
Method = logEntry.RequestMessage.Method,
HttpVersion = logEntry.RequestMessage.HttpVersion,
Headers = logEntry.RequestMessage.Headers,
Cookies = logEntry.RequestMessage.Cookies
};
case BodyType.Json:
logRequestModel.Body = logEntry.RequestMessage.BodyData.BodyAsString; // In case of Json, do also save the Body as string (backwards compatible)
logRequestModel.BodyAsJson = logEntry.RequestMessage.BodyData.BodyAsJson;
break;
if (logEntry.RequestMessage.BodyData != null)
{
logRequestModel.DetectedBodyType = logEntry.RequestMessage.BodyData.DetectedBodyType?.ToString();
logRequestModel.DetectedBodyTypeFromContentType = logEntry.RequestMessage.BodyData.DetectedBodyTypeFromContentType?.ToString();
case BodyType.Bytes:
logRequestModel.BodyAsBytes = logEntry.RequestMessage.BodyData.BodyAsBytes;
break;
switch (logEntry.RequestMessage.BodyData.DetectedBodyType)
{
case BodyType.String:
case BodyType.FormUrlEncoded:
logRequestModel.Body = logEntry.RequestMessage.BodyData.BodyAsString;
break;
case BodyType.Json:
logRequestModel.Body = logEntry.RequestMessage.BodyData.BodyAsString; // In case of Json, do also save the Body as string (backwards compatible)
logRequestModel.BodyAsJson = logEntry.RequestMessage.BodyData.BodyAsJson;
break;
case BodyType.Bytes:
logRequestModel.BodyAsBytes = logEntry.RequestMessage.BodyData.BodyAsBytes;
break;
}
logRequestModel.BodyEncoding = logEntry.RequestMessage.BodyData.Encoding != null
? new EncodingModel
{
EncodingName = logEntry.RequestMessage.BodyData.Encoding.EncodingName,
CodePage = logEntry.RequestMessage.BodyData.Encoding.CodePage,
WebName = logEntry.RequestMessage.BodyData.Encoding.WebName
}
: null;
}
}
LogResponseModel? logResponseModel = null;
if (logEntry.ResponseMessage != null)
{
logResponseModel = new LogResponseModel
{
StatusCode = logEntry.ResponseMessage.StatusCode,
Headers = logEntry.ResponseMessage.Headers
};
if (logEntry.ResponseMessage.FaultType != FaultType.NONE)
{
logResponseModel.FaultType = logEntry.ResponseMessage.FaultType.ToString();
logResponseModel.FaultPercentage = logEntry.ResponseMessage.FaultPercentage;
}
logRequestModel.BodyEncoding = logEntry.RequestMessage.BodyData.Encoding != null
? new EncodingModel
{
EncodingName = logEntry.RequestMessage.BodyData.Encoding.EncodingName,
CodePage = logEntry.RequestMessage.BodyData.Encoding.CodePage,
WebName = logEntry.RequestMessage.BodyData.Encoding.WebName
}
: null;
}
if (logEntry.ResponseMessage.BodyData != null)
{
logResponseModel.BodyOriginal = logEntry.ResponseMessage.BodyOriginal;
logResponseModel.BodyDestination = logEntry.ResponseMessage.BodyDestination;
var logResponseModel = new LogResponseModel
{
StatusCode = logEntry.ResponseMessage.StatusCode,
Headers = logEntry.ResponseMessage.Headers
};
logResponseModel.DetectedBodyType = logEntry.ResponseMessage.BodyData.DetectedBodyType?.ToString();
logResponseModel.DetectedBodyTypeFromContentType = logEntry.ResponseMessage.BodyData.DetectedBodyTypeFromContentType?.ToString();
if (logEntry.ResponseMessage.FaultType != FaultType.NONE)
{
logResponseModel.FaultType = logEntry.ResponseMessage.FaultType.ToString();
logResponseModel.FaultPercentage = logEntry.ResponseMessage.FaultPercentage;
}
MapBody(logEntry, logResponseModel);
if (logEntry.ResponseMessage.BodyData != null)
{
logResponseModel.BodyOriginal = logEntry.ResponseMessage.BodyOriginal;
logResponseModel.BodyDestination = logEntry.ResponseMessage.BodyDestination;
logResponseModel.DetectedBodyType = logEntry.ResponseMessage.BodyData.DetectedBodyType;
logResponseModel.DetectedBodyTypeFromContentType = logEntry.ResponseMessage.BodyData.DetectedBodyTypeFromContentType;
MapBody(logEntry, logResponseModel);
logResponseModel.BodyEncoding = logEntry.ResponseMessage.BodyData.Encoding != null
? new EncodingModel
{
EncodingName = logEntry.ResponseMessage.BodyData.Encoding.EncodingName,
CodePage = logEntry.ResponseMessage.BodyData.Encoding.CodePage,
WebName = logEntry.ResponseMessage.BodyData.Encoding.WebName
}
: null;
logResponseModel.BodyEncoding = logEntry.ResponseMessage.BodyData.Encoding != null
? new EncodingModel
{
EncodingName = logEntry.ResponseMessage.BodyData.Encoding.EncodingName,
CodePage = logEntry.ResponseMessage.BodyData.Encoding.CodePage,
WebName = logEntry.ResponseMessage.BodyData.Encoding.WebName
}
: null;
}
}
return new LogEntryModel
@@ -120,11 +120,11 @@ internal class LogEntryMapper
private void MapBody(ILogEntry logEntry, LogResponseModel logResponseModel)
{
switch (logEntry.ResponseMessage.BodyData!.DetectedBodyType)
switch (logEntry.ResponseMessage?.BodyData?.DetectedBodyType)
{
case BodyType.String:
case BodyType.FormUrlEncoded:
if (!string.IsNullOrEmpty(logEntry.ResponseMessage.BodyData.IsFuncUsed) && _options.DoNotSaveDynamicResponseInLogEntry == true)
if (!string.IsNullOrEmpty(logEntry.ResponseMessage.BodyData.IsFuncUsed) && options.DoNotSaveDynamicResponseInLogEntry == true)
{
logResponseModel.Body = logEntry.ResponseMessage.BodyData.IsFuncUsed;
}

View File

@@ -1,10 +1,6 @@
// 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;

View File

@@ -0,0 +1,19 @@
// Copyright © WireMock.Net
namespace WireMock.WebSockets;
/// <summary>
/// Represents the direction of a WebSocket message.
/// </summary>
internal enum WebSocketMessageDirection
{
/// <summary>
/// Message received from the client.
/// </summary>
Receive,
/// <summary>
/// Message sent to the client.
/// </summary>
Send
}

View File

@@ -1,11 +1,20 @@
// Copyright © WireMock.Net
using System.Diagnostics;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using Microsoft.AspNetCore.Http;
using Stef.Validation;
using WireMock.Extensions;
using WireMock.Logging;
using WireMock.Matchers;
using WireMock.Matchers.Request;
using WireMock.Models;
using WireMock.Owin;
using WireMock.Owin.ActivityTracing;
using WireMock.Types;
using WireMock.Util;
namespace WireMock.WebSockets;
@@ -14,8 +23,6 @@ namespace WireMock.WebSockets;
/// </summary>
public class WireMockWebSocketContext : IWebSocketContext
{
private readonly IWireMockMiddlewareOptions _options;
/// <inheritdoc />
public Guid ConnectionId { get; } = Guid.NewGuid();
@@ -35,6 +42,10 @@ public class WireMockWebSocketContext : IWebSocketContext
internal WebSocketBuilder Builder { get; }
internal IWireMockMiddlewareOptions Options { get; }
internal IWireMockMiddlewareLogger Logger { get; }
/// <summary>
/// Creates a new WebSocketContext
/// </summary>
@@ -44,7 +55,9 @@ public class WireMockWebSocketContext : IWebSocketContext
IRequestMessage requestMessage,
IMapping mapping,
WebSocketConnectionRegistry? registry,
WebSocketBuilder builder)
WebSocketBuilder builder,
IWireMockMiddlewareOptions options,
IWireMockMiddlewareLogger logger)
{
HttpContext = Guard.NotNull(httpContext);
WebSocket = Guard.NotNull(webSocket);
@@ -52,26 +65,19 @@ public class WireMockWebSocketContext : IWebSocketContext
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");
}
Options = Guard.NotNull(options);
Logger = Guard.NotNull(logger);
}
/// <inheritdoc />
public Task SendAsync(string text, CancellationToken cancellationToken = default)
{
var bytes = Encoding.UTF8.GetBytes(text);
return WebSocket.SendAsync(
return SendAsyncInternal(
new ArraySegment<byte>(bytes),
WebSocketMessageType.Text,
true,
text,
cancellationToken
);
}
@@ -79,18 +85,157 @@ public class WireMockWebSocketContext : IWebSocketContext
/// <inheritdoc />
public Task SendAsync(byte[] bytes, CancellationToken cancellationToken = default)
{
return WebSocket.SendAsync(
return SendAsyncInternal(
new ArraySegment<byte>(bytes),
WebSocketMessageType.Binary,
true,
bytes,
cancellationToken
);
}
/// <inheritdoc />
public Task CloseAsync(WebSocketCloseStatus closeStatus, string statusDescription)
private async Task SendAsyncInternal(
ArraySegment<byte> buffer,
WebSocketMessageType messageType,
bool endOfMessage,
object? data,
CancellationToken cancellationToken)
{
return WebSocket.CloseAsync(closeStatus, statusDescription, CancellationToken.None);
Activity? activity = null;
var shouldTrace = Options.ActivityTracingOptions is not null;
if (shouldTrace)
{
activity = WireMockActivitySource.StartWebSocketMessageActivity(WebSocketMessageDirection.Send, Mapping.Guid);
WireMockActivitySource.EnrichWithWebSocketMessage(
activity,
messageType,
buffer.Count,
endOfMessage,
data as string,
Options.ActivityTracingOptions
);
}
try
{
await WebSocket.SendAsync(buffer, messageType, endOfMessage, cancellationToken).ConfigureAwait(false);
// Log the send operation
if (Options.MaxRequestLogCount is null or > 0)
{
LogWebSocketMessage(WebSocketMessageDirection.Send, messageType, data, activity);
}
}
catch (Exception ex)
{
WireMockActivitySource.RecordException(activity, ex);
throw;
}
finally
{
activity?.Dispose();
}
}
private void LogWebSocketMessage(
WebSocketMessageDirection direction,
WebSocketMessageType messageType,
object? data,
Activity? activity)
{
// Create body data
IBodyData bodyData;
if (messageType == WebSocketMessageType.Text && data is string textContent)
{
bodyData = new BodyData
{
BodyAsString = textContent,
DetectedBodyType = BodyType.String
};
}
else if (messageType == WebSocketMessageType.Binary && data is byte[] binary)
{
bodyData = new BodyData
{
BodyAsBytes = binary,
DetectedBodyType = BodyType.Bytes
};
}
else
{
bodyData = new BodyData
{
BodyAsString = messageType.ToString(),
DetectedBodyType = BodyType.Bytes
};
}
// Create a pseudo-request or pseudo-response depending on direction
RequestMessage? requestMessage = null;
IResponseMessage? responseMessage = null;
var method = $"WS_{direction.ToString().ToUpperInvariant()}";
if (direction == WebSocketMessageDirection.Receive)
{
// Received message - log as request
requestMessage = new RequestMessage(
new UrlDetails(RequestMessage.Url),
method,
RequestMessage.ClientIP,
bodyData,
null,
null
)
{
DateTime = DateTime.UtcNow
};
}
else
{
// Sent message - log as response
responseMessage = new ResponseMessage
{
StatusCode = HttpStatusCode.SwitchingProtocols, // WebSocket status
BodyData = bodyData,
DateTime = DateTime.UtcNow
};
}
// Create a perfect match result
var requestMatchResult = new RequestMatchResult();
requestMatchResult.AddScore(typeof(WebSocketMessageDirection), MatchScores.Perfect, null);
// Create log entry
var logEntry = new LogEntry
{
Guid = Guid.NewGuid(),
RequestMessage = requestMessage,
ResponseMessage = responseMessage,
MappingGuid = Mapping.Guid,
MappingTitle = Mapping.Title,
RequestMatchResult = requestMatchResult
};
// Enrich activity if present
if (activity != null && Options.ActivityTracingOptions != null)
{
WireMockActivitySource.EnrichWithLogEntry(activity, logEntry, Options.ActivityTracingOptions);
}
// Log using LogLogEntry
Logger.LogLogEntry(logEntry, Options.MaxRequestLogCount is null or > 0);
activity?.Dispose();
}
/// <inheritdoc />
public async Task CloseAsync(WebSocketCloseStatus closeStatus, string statusDescription)
{
await WebSocket.CloseAsync(closeStatus, statusDescription, CancellationToken.None);
LogWebSocketMessage(WebSocketMessageDirection.Send, WebSocketMessageType.Close, $"CloseStatus: {closeStatus}, Description: {statusDescription}", null);
}
/// <inheritdoc />
@@ -108,7 +253,7 @@ public class WireMockWebSocketContext : IWebSocketContext
}
// Use the same logic as WireMockMiddleware
if (_options.Scenarios.TryGetValue(Mapping.Scenario, out var scenarioState))
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;
@@ -121,7 +266,7 @@ public class WireMockWebSocketContext : IWebSocketContext
else
{
// Create new scenario state if it doesn't exist
_options.Scenarios.TryAdd(Mapping.Scenario, new ScenarioState
Options.Scenarios.TryAdd(Mapping.Scenario, new ScenarioState
{
Name = Mapping.Scenario,
NextState = nextState,
@@ -144,7 +289,7 @@ public class WireMockWebSocketContext : IWebSocketContext
}
// Ensure scenario exists
if (!_options.Scenarios.TryGetValue(Mapping.Scenario, out var scenario))
if (!Options.Scenarios.TryGetValue(Mapping.Scenario, out var scenario))
{
return;
}

View File

@@ -67,7 +67,11 @@ public sealed class TestOutputHelperWireMockLogger : IWireMockLogger
/// <inheritdoc />
public void DebugRequestResponse(LogEntryModel logEntryModel, bool isAdminRequest)
{
var message = JsonConvert.SerializeObject(logEntryModel, Formatting.Indented);
var message = JsonConvert.SerializeObject(logEntryModel, new JsonSerializerSettings
{
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Ignore
});
_testOutputHelper.WriteLine(Format("DebugRequestResponse", "Admin[{0}] {1}", isAdminRequest, message));
}

View File

@@ -67,7 +67,11 @@ public sealed class TestOutputHelperWireMockLogger : IWireMockLogger
/// <inheritdoc />
public void DebugRequestResponse(LogEntryModel logEntryModel, bool isAdminRequest)
{
var message = JsonConvert.SerializeObject(logEntryModel, Formatting.Indented);
var message = JsonConvert.SerializeObject(logEntryModel, new JsonSerializerSettings
{
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Ignore
});
_testOutputHelper.WriteLine(Format("DebugRequestResponse", "Admin[{0}] {1}", isAdminRequest, message));
}