Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/WireMock.Net.Abstractions/Admin/Requests/NearMissModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright © WireMock.Net

namespace WireMock.Admin.Requests;

/// <summary>
/// NearMiss Model: a mapping which did not (fully) match a request.
/// </summary>
public class NearMissModel
{
/// <summary>
/// The unique identifier of the log entry for the request. [Optional]
/// </summary>
public Guid? LogEntryGuid { get; set; }

/// <summary>
/// The request.
/// </summary>
public LogRequestModel? Request { get; set; }

/// <summary>
/// The unique identifier of the mapping.
/// </summary>
public Guid MappingGuid { get; set; }

/// <summary>
/// The title of the mapping.
/// </summary>
public string? MappingTitle { get; set; }

/// <summary>
/// The request match result, containing the score per matcher.
/// </summary>
public LogRequestMatchModel? RequestMatchResult { get; set; }
}
14 changes: 14 additions & 0 deletions src/WireMock.Net.Abstractions/Admin/Requests/RequestCountModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright © WireMock.Net

namespace WireMock.Admin.Requests;

/// <summary>
/// RequestCount Model
/// </summary>
public class RequestCountModel
{
/// <summary>
/// The number of requests which match the criteria.
/// </summary>
public int Count { get; set; }
}
34 changes: 34 additions & 0 deletions src/WireMock.Net.Abstractions/Matchers/Request/NearMiss.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright © WireMock.Net

namespace WireMock.Matchers.Request;

/// <summary>
/// A mapping which did not (fully) match a request, together with the match result explaining why.
/// </summary>
public class NearMiss
{
/// <summary>
/// The request which was evaluated against the mapping.
/// </summary>
public required IRequestMessage RequestMessage { get; init; }

/// <summary>
/// The unique identifier of the log entry for the request. [Optional]
/// </summary>
public Guid? LogEntryGuid { get; init; }

/// <summary>
/// The unique identifier of the mapping.
/// </summary>
public required Guid MappingGuid { get; init; }

/// <summary>
/// The title of the mapping. [Optional]
/// </summary>
public string? MappingTitle { get; init; }

/// <summary>
/// The match result, containing the score per matcher.
/// </summary>
public required IRequestMatchResult RequestMatchResult { get; init; }
}
33 changes: 33 additions & 0 deletions src/WireMock.Net.Abstractions/Server/IWireMockServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,39 @@ public interface IWireMockServer : IDisposable
/// <returns>The <see cref="IReadOnlyList{ILogEntry}"/>.</returns>
IReadOnlyList<ILogEntry> FindLogEntries(params IRequestMatcher[] matchers);

/// <summary>
/// Get the log-entries for the requests which did not fully match any mapping.
/// This includes the requests which were served by a partial match when AllowPartialMapping is enabled.
/// </summary>
/// <returns>The <see cref="IReadOnlyList{ILogEntry}"/>.</returns>
IReadOnlyList<ILogEntry> FindUnmatchedLogEntries();

/// <summary>
/// Find the mappings which partially match the request (the near misses), ordered by best match first.
/// Perfect matches and mappings which do not match at all are excluded.
/// Use this to find out why a request did not match the expected mapping.
/// </summary>
/// <param name="requestMessage">The request to evaluate.</param>
/// <param name="maxResults">The maximum number of near misses to return. Default is 3.</param>
/// <returns>The <see cref="IReadOnlyList{NearMiss}"/>.</returns>
IReadOnlyList<NearMiss> FindNearMisses(IRequestMessage requestMessage, int maxResults = 3);

/// <summary>
/// Find the mappings which partially match the request of a log-entry (the near misses), ordered by best match first.
/// Perfect matches and mappings which do not match at all are excluded.
/// </summary>
/// <param name="logEntry">The log-entry for which to evaluate the request.</param>
/// <param name="maxResults">The maximum number of near misses to return. Default is 3.</param>
/// <returns>The <see cref="IReadOnlyList{NearMiss}"/>.</returns>
IReadOnlyList<NearMiss> FindNearMisses(ILogEntry logEntry, int maxResults = 3);

/// <summary>
/// Find the near misses for all requests which did not fully match any mapping.
/// </summary>
/// <param name="maxResultsPerRequest">The maximum number of near misses to return per request. Default is 3.</param>
/// <returns>The <see cref="IReadOnlyList{NearMiss}"/>.</returns>
IReadOnlyList<NearMiss> FindNearMissesForUnmatchedRequests(int maxResultsPerRequest = 3);

/// <summary>
/// Reads a static mapping file and adds or updates a single mapping.
///
Expand Down
2 changes: 2 additions & 0 deletions src/WireMock.Net.Minimal/Owin/AdminPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ internal sealed class AdminPaths(string? adminPath) : IAdminPaths

public RegexMatcher RequestsGuidPathMatcher => new($@"^{PrefixRegexEscaped}\/requests\/([0-9A-Fa-f]{{8}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{12}})$");

public RegexMatcher RequestsGuidNearMissesPathMatcher => new($@"^{PrefixRegexEscaped}\/requests\/([0-9A-Fa-f]{{8}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{12}})\/near-misses$");

public RegexMatcher ScenariosNameMatcher => new($@"^{PrefixRegexEscaped}\/scenarios\/.+$");

public RegexMatcher ScenariosNameWithStateMatcher => new($@"^{PrefixRegexEscaped}\/scenarios\/.+\/state$");
Expand Down
2 changes: 2 additions & 0 deletions src/WireMock.Net.Minimal/Owin/IAdminPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ internal interface IAdminPaths

RegexMatcher RequestsGuidPathMatcher { get; }

RegexMatcher RequestsGuidNearMissesPathMatcher { get; }

RegexMatcher ScenariosNameMatcher { get; }

RegexMatcher ScenariosNameWithStateMatcher { get; }
Expand Down
2 changes: 2 additions & 0 deletions src/WireMock.Net.Minimal/Owin/IMappingMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ namespace WireMock.Owin;
internal interface IMappingMatcher
{
(MappingMatcherResult? Match, MappingMatcherResult? Partial) FindBestMatch(RequestMessage request);

IReadOnlyList<MappingMatcherResult> FindNearMisses(IRequestMessage request, int maxResults);
}
33 changes: 33 additions & 0 deletions src/WireMock.Net.Minimal/Owin/MappingMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,39 @@ internal class MappingMatcher(IWireMockMiddlewareOptions options, IRandomizerDou
return (match, partialMatch);
}

public IReadOnlyList<MappingMatcherResult> FindNearMisses(IRequestMessage request, int maxResults)
{
Guard.NotNull(request);

var nearMisses = new List<MappingMatcherResult>();

// Probability is not taken into account here: a mapping which was skipped based on probability can still be a near miss.
foreach (var mapping in _options.Mappings.Values.Where(m => !m.IsAdminInterface && !m.IsDisabled && m.TimeSettings.IsValid()))
{
try
{
var requestMatchResult = mapping.GetRequestMatchResult(request, GetNextState(mapping));
if (requestMatchResult.MatchDetails.All(md => md.Exception == null))
{
nearMisses.Add(new MappingMatcherResult(mapping, requestMatchResult));
}
}
catch (Exception ex)
{
LogException(mapping, ex);
}
}

// A near miss is a mapping which matches the request partially: perfect matches and complete mismatches are excluded.
return nearMisses
.Where(nm => !nm.RequestMatchResult.IsPerfectMatch && nm.RequestMatchResult.AverageTotalScore > 0.0)
.OrderBy(nm => nm.RequestMatchResult)
.ThenBy(nm => nm.Mapping.Priority)
.ThenByDescending(nm => nm.Mapping.UpdatedAt)
.Take(maxResults)
.ToArray();
}

private void LogException(IMapping mapping, Exception ex)
{
_options.Logger.Error($"Getting a Request MatchResult for Mapping '{mapping.Guid}' failed. This mapping will not be evaluated. Exception: {ex}");
Expand Down
123 changes: 69 additions & 54 deletions src/WireMock.Net.Minimal/Serialization/LogEntryMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,60 +14,7 @@ internal class LogEntryMapper(IWireMockMiddlewareOptions options)
{
public LogEntryModel Map(ILogEntry logEntry)
{
LogRequestModel? logRequestModel = null;
if (logEntry.RequestMessage != null)
{
logRequestModel = new LogRequestModel
{
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)
{
case BodyType.String:
case BodyType.FormUrlEncoded:
case BodyType.WebSocketText:
case BodyType.WebSocketClose:
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:
case BodyType.WebSocketBinary:
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;
}
}
var logRequestModel = logEntry.RequestMessage != null ? MapRequest(logEntry.RequestMessage) : null;

LogResponseModel? logResponseModel = null;
if (logEntry.ResponseMessage != null)
Expand Down Expand Up @@ -123,6 +70,74 @@ public LogEntryModel Map(ILogEntry logEntry)
};
}

public NearMissModel Map(NearMiss nearMiss)
{
return new NearMissModel
{
LogEntryGuid = nearMiss.LogEntryGuid,
Request = MapRequest(nearMiss.RequestMessage),
MappingGuid = nearMiss.MappingGuid,
MappingTitle = nearMiss.MappingTitle,
RequestMatchResult = Map(nearMiss.RequestMatchResult)
};
}

private static LogRequestModel MapRequest(IRequestMessage requestMessage)
{
var logRequestModel = new LogRequestModel
{
DateTime = requestMessage.DateTime,
ClientIP = requestMessage.ClientIP,
Path = requestMessage.Path,
AbsolutePath = requestMessage.AbsolutePath,
Url = requestMessage.Url,
AbsoluteUrl = requestMessage.AbsoluteUrl,
ProxyUrl = requestMessage.ProxyUrl,
Query = requestMessage.Query,
Method = requestMessage.Method,
HttpVersion = requestMessage.HttpVersion,
Headers = requestMessage.Headers,
Cookies = requestMessage.Cookies
};

if (requestMessage.BodyData != null)
{
logRequestModel.DetectedBodyType = requestMessage.BodyData.DetectedBodyType?.ToString();
logRequestModel.DetectedBodyTypeFromContentType = requestMessage.BodyData.DetectedBodyTypeFromContentType?.ToString();

switch (requestMessage.BodyData.DetectedBodyType)
{
case BodyType.String:
case BodyType.FormUrlEncoded:
case BodyType.WebSocketText:
case BodyType.WebSocketClose:
logRequestModel.Body = requestMessage.BodyData.BodyAsString;
break;

case BodyType.Json:
logRequestModel.Body = requestMessage.BodyData.BodyAsString; // In case of Json, do also save the Body as string (backwards compatible)
logRequestModel.BodyAsJson = requestMessage.BodyData.BodyAsJson;
break;

case BodyType.Bytes:
case BodyType.WebSocketBinary:
logRequestModel.BodyAsBytes = requestMessage.BodyData.BodyAsBytes;
break;
}

logRequestModel.BodyEncoding = requestMessage.BodyData.Encoding != null
? new EncodingModel
{
EncodingName = requestMessage.BodyData.Encoding.EncodingName,
CodePage = requestMessage.BodyData.Encoding.CodePage,
WebName = requestMessage.BodyData.Encoding.WebName
}
: null;
}

return logRequestModel;
}

private void MapBody(ILogEntry logEntry, LogResponseModel logResponseModel)
{
switch (logEntry.ResponseMessage?.BodyData?.DetectedBodyType)
Expand Down
Loading
Loading