diff --git a/src/WireMock.Net.Abstractions/Admin/Requests/LogEntryModel.cs b/src/WireMock.Net.Abstractions/Admin/Requests/LogEntryModel.cs index b9c5460a..d5756eea 100644 --- a/src/WireMock.Net.Abstractions/Admin/Requests/LogEntryModel.cs +++ b/src/WireMock.Net.Abstractions/Admin/Requests/LogEntryModel.cs @@ -1,7 +1,5 @@ // Copyright © WireMock.Net -using System; - namespace WireMock.Admin.Requests; /// @@ -17,12 +15,12 @@ public class LogEntryModel /// /// The request. /// - public required LogRequestModel Request { get; init; } + public LogRequestModel? Request { get; init; } /// /// The response. /// - public required LogResponseModel Response { get; init; } + public LogResponseModel? Response { get; init; } /// /// The mapping unique identifier. diff --git a/src/WireMock.Net.Abstractions/Admin/Requests/LogRequestModel.cs b/src/WireMock.Net.Abstractions/Admin/Requests/LogRequestModel.cs index 44f9ecc0..22d58b17 100644 --- a/src/WireMock.Net.Abstractions/Admin/Requests/LogRequestModel.cs +++ b/src/WireMock.Net.Abstractions/Admin/Requests/LogRequestModel.cs @@ -1,7 +1,5 @@ // Copyright © WireMock.Net -using System; -using System.Collections.Generic; using WireMock.Admin.Mappings; using WireMock.Types; diff --git a/src/WireMock.Net.Abstractions/Admin/Requests/LogResponseModel.cs b/src/WireMock.Net.Abstractions/Admin/Requests/LogResponseModel.cs index 946449d6..7f2b3b56 100644 --- a/src/WireMock.Net.Abstractions/Admin/Requests/LogResponseModel.cs +++ b/src/WireMock.Net.Abstractions/Admin/Requests/LogResponseModel.cs @@ -64,12 +64,12 @@ public class LogResponseModel /// /// The detected body type (detection based on body content). /// - public BodyType? DetectedBodyType { get; set; } + public string? DetectedBodyType { get; set; } /// /// The detected body type (detection based on Content-Type). /// - public BodyType? DetectedBodyTypeFromContentType { get; set; } + public string? DetectedBodyTypeFromContentType { get; set; } /// /// The FaultType. diff --git a/src/WireMock.Net.Abstractions/IRequestMessage.cs b/src/WireMock.Net.Abstractions/IRequestMessage.cs index 7b42291f..02a23b63 100644 --- a/src/WireMock.Net.Abstractions/IRequestMessage.cs +++ b/src/WireMock.Net.Abstractions/IRequestMessage.cs @@ -1,7 +1,5 @@ // Copyright © WireMock.Net -using System; -using System.Collections.Generic; using System.Security.Cryptography.X509Certificates; using WireMock.Types; using WireMock.Util; diff --git a/src/WireMock.Net.Abstractions/IResponseMessage.cs b/src/WireMock.Net.Abstractions/IResponseMessage.cs index 7951e2ed..232100a6 100644 --- a/src/WireMock.Net.Abstractions/IResponseMessage.cs +++ b/src/WireMock.Net.Abstractions/IResponseMessage.cs @@ -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 /// object? StatusCode { get; set; } + /// + /// Gets the DateTime. + /// + DateTime DateTime { get; } + /// /// Adds the header. /// diff --git a/src/WireMock.Net.Abstractions/Logging/ILogEntry.cs b/src/WireMock.Net.Abstractions/Logging/ILogEntry.cs index 4f5d801e..44d090c7 100644 --- a/src/WireMock.Net.Abstractions/Logging/ILogEntry.cs +++ b/src/WireMock.Net.Abstractions/Logging/ILogEntry.cs @@ -38,20 +38,20 @@ public interface ILogEntry /// /// Gets the partial match result. /// - IRequestMatchResult PartialMatchResult { get; } + IRequestMatchResult? PartialMatchResult { get; } /// /// Gets the request match result. /// - IRequestMatchResult RequestMatchResult { get; } + IRequestMatchResult? RequestMatchResult { get; } /// /// Gets the request message. /// - IRequestMessage RequestMessage { get; } + IRequestMessage? RequestMessage { get; } /// /// Gets the response message. /// - IResponseMessage ResponseMessage { get; } + IResponseMessage? ResponseMessage { get; } } \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/Logging/LogEntry.cs b/src/WireMock.Net.Minimal/Logging/LogEntry.cs index 33ad6ee1..35bc8772 100644 --- a/src/WireMock.Net.Minimal/Logging/LogEntry.cs +++ b/src/WireMock.Net.Minimal/Logging/LogEntry.cs @@ -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; } /// - public IRequestMessage RequestMessage { get; set; } = null!; + public IRequestMessage? RequestMessage { get; set; } /// - public IResponseMessage ResponseMessage { get; set; } = null!; + public IResponseMessage? ResponseMessage { get; set; } /// - public IRequestMatchResult RequestMatchResult { get; set; } = null!; + public IRequestMatchResult? RequestMatchResult { get; set; } /// public Guid? MappingGuid { get; set; } @@ -35,5 +34,5 @@ public class LogEntry : ILogEntry public string? PartialMappingTitle { get; set; } /// - public IRequestMatchResult PartialMatchResult { get; set; } = null!; + public IRequestMatchResult? PartialMatchResult { get; set; } } \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/Owin/ActivityTracing/WireMockActivitySource.cs b/src/WireMock.Net.Minimal/Owin/ActivityTracing/WireMockActivitySource.cs index 6d9bf2dd..574d2e07 100644 --- a/src/WireMock.Net.Minimal/Owin/ActivityTracing/WireMockActivitySource.cs +++ b/src/WireMock.Net.Minimal/Owin/ActivityTracing/WireMockActivitySource.cs @@ -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()); } + + /// + /// Starts a new activity for a WebSocket message. + /// + /// The direction of the message. + /// The GUID of the mapping handling the WebSocket. + /// The started activity, or null if tracing is not enabled. + 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; + } + + /// + /// Enriches an activity with WebSocket message information. + /// + 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"); + } } \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/Owin/ActivityTracing/WireMockSemanticConventions.cs b/src/WireMock.Net.Minimal/Owin/ActivityTracing/WireMockSemanticConventions.cs index 57de76e6..b55cccff 100644 --- a/src/WireMock.Net.Minimal/Owin/ActivityTracing/WireMockSemanticConventions.cs +++ b/src/WireMock.Net.Minimal/Owin/ActivityTracing/WireMockSemanticConventions.cs @@ -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"; } \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareLogger.cs b/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareLogger.cs index 777ad930..e80d4019 100644 --- a/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareLogger.cs +++ b/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareLogger.cs @@ -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); } \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs b/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs index 77cf3fc1..20f9c267 100644 --- a/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs +++ b/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs @@ -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 { diff --git a/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareLogger.cs b/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareLogger.cs index 0200cbb1..a5c9d602 100644 --- a/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareLogger.cs +++ b/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareLogger.cs @@ -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); } diff --git a/src/WireMock.Net.Minimal/ResponseMessage.cs b/src/WireMock.Net.Minimal/ResponseMessage.cs index 3c269f64..524e6575 100644 --- a/src/WireMock.Net.Minimal/ResponseMessage.cs +++ b/src/WireMock.Net.Minimal/ResponseMessage.cs @@ -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 /// public double? FaultPercentage { get; set; } + /// + public DateTime DateTime { get; set; } + /// public void AddHeader(string name, string value) { diff --git a/src/WireMock.Net.Minimal/ResponseProviders/WebSocketResponseProvider.cs b/src/WireMock.Net.Minimal/ResponseProviders/WebSocketResponseProvider.cs index 81275e0b..8c1c65ce 100644 --- a/src/WireMock.Net.Minimal/ResponseProviders/WebSocketResponseProvider.cs +++ b/src/WireMock.Net.Minimal/ResponseProviders/WebSocketResponseProvider.cs @@ -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(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(buffer, 0, result.Count), - result.MessageType, - result.EndOfMessage, - cts.Token - ).ConfigureAwait(false); + try + { + var result = await context.WebSocket.ReceiveAsync( + new ArraySegment(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(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(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(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(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(buffer, 0, result.Count), - result.MessageType, - result.EndOfMessage, - CancellationToken.None - ); + try + { + var result = await source.ReceiveAsync(new ArraySegment(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(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(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(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 diff --git a/src/WireMock.Net.Minimal/Serialization/LogEntryMapper.cs b/src/WireMock.Net.Minimal/Serialization/LogEntryMapper.cs index 116c825a..db942197 100644 --- a/src/WireMock.Net.Minimal/Serialization/LogEntryMapper.cs +++ b/src/WireMock.Net.Minimal/Serialization/LogEntryMapper.cs @@ -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; } diff --git a/src/WireMock.Net.Minimal/Server/WireMockServer.WebSocket.cs b/src/WireMock.Net.Minimal/Server/WireMockServer.WebSocket.cs index aef3f354..2bcc7686 100644 --- a/src/WireMock.Net.Minimal/Server/WireMockServer.WebSocket.cs +++ b/src/WireMock.Net.Minimal/Server/WireMockServer.WebSocket.cs @@ -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; diff --git a/src/WireMock.Net.Minimal/WebSockets/WebSocketMessageDirection.cs b/src/WireMock.Net.Minimal/WebSockets/WebSocketMessageDirection.cs new file mode 100644 index 00000000..372d84b2 --- /dev/null +++ b/src/WireMock.Net.Minimal/WebSockets/WebSocketMessageDirection.cs @@ -0,0 +1,19 @@ +// Copyright © WireMock.Net + +namespace WireMock.WebSockets; + +/// +/// Represents the direction of a WebSocket message. +/// +internal enum WebSocketMessageDirection +{ + /// + /// Message received from the client. + /// + Receive, + + /// + /// Message sent to the client. + /// + Send +} \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/WebSockets/WireMockWebSocketContext.cs b/src/WireMock.Net.Minimal/WebSockets/WireMockWebSocketContext.cs index ec3d4814..a6fe28e3 100644 --- a/src/WireMock.Net.Minimal/WebSockets/WireMockWebSocketContext.cs +++ b/src/WireMock.Net.Minimal/WebSockets/WireMockWebSocketContext.cs @@ -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; /// public class WireMockWebSocketContext : IWebSocketContext { - private readonly IWireMockMiddlewareOptions _options; - /// 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; } + /// /// Creates a new WebSocketContext /// @@ -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(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); } /// public Task SendAsync(string text, CancellationToken cancellationToken = default) { var bytes = Encoding.UTF8.GetBytes(text); - return WebSocket.SendAsync( + return SendAsyncInternal( new ArraySegment(bytes), WebSocketMessageType.Text, true, + text, cancellationToken ); } @@ -79,18 +85,157 @@ public class WireMockWebSocketContext : IWebSocketContext /// public Task SendAsync(byte[] bytes, CancellationToken cancellationToken = default) { - return WebSocket.SendAsync( + return SendAsyncInternal( new ArraySegment(bytes), WebSocketMessageType.Binary, true, + bytes, cancellationToken ); } - /// - public Task CloseAsync(WebSocketCloseStatus closeStatus, string statusDescription) + private async Task SendAsyncInternal( + ArraySegment 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(); + } + + /// + 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); } /// @@ -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; } diff --git a/src/WireMock.Net.xUnit.v3/TestOutputHelperWireMockLogger.cs b/src/WireMock.Net.xUnit.v3/TestOutputHelperWireMockLogger.cs index 88c4993b..44a46a3e 100644 --- a/src/WireMock.Net.xUnit.v3/TestOutputHelperWireMockLogger.cs +++ b/src/WireMock.Net.xUnit.v3/TestOutputHelperWireMockLogger.cs @@ -67,7 +67,11 @@ public sealed class TestOutputHelperWireMockLogger : IWireMockLogger /// 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)); } diff --git a/src/WireMock.Net.xUnit/TestOutputHelperWireMockLogger.cs b/src/WireMock.Net.xUnit/TestOutputHelperWireMockLogger.cs index 0f0cbed2..bda59781 100644 --- a/src/WireMock.Net.xUnit/TestOutputHelperWireMockLogger.cs +++ b/src/WireMock.Net.xUnit/TestOutputHelperWireMockLogger.cs @@ -67,7 +67,11 @@ public sealed class TestOutputHelperWireMockLogger : IWireMockLogger /// 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)); }