Compare commits

..
12 Commits
Author SHA1 Message Date
Stef Heyenrath f6dc299ecb 2.20.0 2026-10-23 17:54:30 +02:00
Mattias SundströmandMattias dc14136492 Add near-miss diagnostics and request count/unmatched endpoints to the admin API (#1521)
* Add near-miss diagnostics and request count/unmatched admin endpoints (#171)

Add IWireMockServer.FindUnmatchedLogEntries, FindNearMisses and
FindNearMissesForUnmatchedRequests, which rank the mappings that
partially match a request by score.

Expose them through the admin API (POST requests/count,
GET requests/unmatched, GET requests/unmatched/near-misses and
GET requests/{guid}/near-misses), the RestClient and the OpenAPI document.

* Make scenario delay tests independent of server warm-up time

The two *_StateTransitions_BeforeDelayCompletes tests waited a fixed
100 ms for the scenario state to change, which fails on a cold server.
Poll server.Scenarios until the expected state is reached instead.

---------

Co-authored-by: Mattias <13809209+Quattordici@users.noreply.github.com>
2026-10-23 17:48:54 +02:00
Stef Heyenrath c94824bd64 Update Generate-ReleaseNotes.cmd 2026-10-19 19:30:12 +02:00
Stef HeyenrathandCopilot Autofix powered by AI 54e16fc045 Potential fix for code scanning alert no. 8: Workflow does not contain permissions (#1523)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-10-19 19:25:01 +02:00
Stef Heyenrath 7d0de75ab1 Downgrade JsonPath.Net from 3.0.2 to 2.2.0 to avoid Open Source Maintenance Fee issues (#1522) 2026-10-19 18:42:43 +02:00
Stef Heyenrath afb01a0fa6 Bump MessagePack from 2.5.302 to 2.5.303 (#1519) 2026-10-18 10:51:38 +02:00
Mattias SundströmandMattias 0132ec873a Add ScribanRegistrationCallback to register custom Scriban functions (#1515) (#1516)
- Add WireMockServerSettings.ScribanRegistrationCallback
  (Action<object, IFileSystemHandler>?) so code-configured servers can add
  functions, objects or variables to Scriban and Scriban-DotLiquid templates.
- Pass the settings to ScribanContextFactory, which invokes the callback with
  a new ScriptObject per transformer context.
- Push the custom globals beneath the template model in ScribanContext so
  request, response, mapping and data take precedence; keep the original
  Template.Render call when no callback is configured.
- Add the strongly typed WithScribanRegistration extension method.
- Add a Scriban example to the .NET 8 console app.
- Make the proxy tests use a local target server instead of www.google.com,
  and skip the IPv6 loopback test when the OS has no IPv6 support.

Co-authored-by: Mattias <13809209+Quattordici@users.noreply.github.com>
2026-10-18 09:29:11 +02:00
Stef Heyenrath 64734c4f92 <VersionPrefix>2.19.0</VersionPrefix> 2026-10-07 18:25:30 +02:00
Stef Heyenrath a5589e1e2f 2.19.0 2026-10-07 18:22:51 +02:00
Stef HeyenrathandStef Heyenrath cb4503b1a6 Fix DateTime in Response (#1514)
* Fix DateTime in Response

* // Set the DateTime from the Response to the current DateTime

---------

Co-authored-by: Stef Heyenrath <stef.heyenrath@apg.nl>
2026-10-07 17:35:58 +02:00
Stef Heyenrath dd762f218b Use IDateTimeUtils in WireMockWebSocketContext (#1512) 2026-10-06 22:58:33 +02:00
Stef Heyenrath b0f9924933 Add example /test (#1513) 2026-10-06 22:51:17 +02:00
58 changed files with 1793 additions and 131 deletions

No files matched your search

+3
View File
@@ -8,6 +8,9 @@ on:
branches:
- 'main'
permissions:
contents: read
jobs:
windows-build-and-run:
name: Run Tests on Windows
+13
View File
@@ -1,3 +1,16 @@
# 2.20.0 (23 October 2026)
- [#1516](https://github.com/wiremock/WireMock.Net/pull/1516) - Add ScribanRegistrationCallback to register custom Scriban functions [feature] contributed by [Quattordici](https://github.com/Quattordici)
- [#1519](https://github.com/wiremock/WireMock.Net/pull/1519) - Bump MessagePack from 2.5.302 to 2.5.303 [dependencies] contributed by [StefH](https://github.com/StefH)
- [#1521](https://github.com/wiremock/WireMock.Net/pull/1521) - Add near-miss diagnostics and request count/unmatched endpoints to the admin API [feature] contributed by [Quattordici](https://github.com/Quattordici)
- [#1522](https://github.com/wiremock/WireMock.Net/pull/1522) - Downgrade JsonPath.Net from 3.0.2 to 2.2.0 to avoid Open Source Maintenance Fee issues [dependencies] contributed by [StefH](https://github.com/StefH)
- [#1515](https://github.com/wiremock/WireMock.Net/issues/1515) - Allow registering custom Scriban functions (ScribanRegistrationCallback), like HandlebarsRegistrationCallback [feature]
- [#1520](https://github.com/wiremock/WireMock.Net/issues/1520) - json-everything sub-dependency now has Open Source Maintenance Fee [dependencies]
# 2.19.0 (07 October 2026)
- [#1512](https://github.com/wiremock/WireMock.Net/pull/1512) - Use IDateTimeUtils in WireMockWebSocketContext [bug] contributed by [StefH](https://github.com/StefH)
- [#1514](https://github.com/wiremock/WireMock.Net/pull/1514) - Fix DateTime in Response [bug] contributed by [StefH](https://github.com/StefH)
- [#1511](https://github.com/wiremock/WireMock.Net/issues/1511) - Incorrect response datetime for mapped response [bug]
# 2.18.0 (22 September 2026)
- [#1510](https://github.com/wiremock/WireMock.Net/pull/1510) - WireMock.Net.OpenApiParser: fix depencency on RamlToOpenApiConverter.SourceOnly [bug] contributed by [StefH](https://github.com/StefH)
- [#1508](https://github.com/wiremock/WireMock.Net/issues/1508) - Transitive Dependency Conflict with Microsoft.OpenApi in 2.16.0 [bug]
+1 -1
View File
@@ -4,7 +4,7 @@
</PropertyGroup>
<PropertyGroup>
<VersionPrefix>2.18.0</VersionPrefix>
<VersionPrefix>2.20.0</VersionPrefix>
<PackageIcon>WireMock.Net-Logo.png</PackageIcon>
<PackageProjectUrl>https://github.com/wiremock/WireMock.Net</PackageProjectUrl>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
+12 -2
View File
@@ -1,7 +1,17 @@
rem https://github.com/StefH/GitHubReleaseNotes
SET version=2.18.0
@echo off
setlocal
for /f "usebackq delims=" %%v in (`powershell -NoProfile -Command "[xml]$props = Get-Content 'Directory.Build.props'; $props.Project.PropertyGroup | ForEach-Object { $_.VersionPrefix } | Where-Object { $_ } | Select-Object -First 1"`) do set version=%%v
if "%version%"=="" (
echo Unable to determine VersionPrefix from Directory.Build.props.
exit /b 1
)
GitHubReleaseNotes --output CHANGELOG.md --skip-empty-releases --exclude-labels wontfix test question invalid doc duplicate example environment --version %version% --token %GH_TOKEN%
if errorlevel 1 exit /b %errorlevel%
GitHubReleaseNotes --output PackageReleaseNotes.txt --skip-empty-releases --exclude-labels wontfix test question invalid doc duplicate example environment --template PackageReleaseNotes.template --version %version% --token %GH_TOKEN%
GitHubReleaseNotes --output PackageReleaseNotes.txt --skip-empty-releases --exclude-labels wontfix test question invalid doc duplicate example environment --template PackageReleaseNotes.template --version %version% --token %GH_TOKEN%
if errorlevel 1 exit /b %errorlevel%
+7 -3
View File
@@ -1,5 +1,9 @@
# 2.18.0 (22 September 2026)
- #1510 WireMock.Net.OpenApiParser: fix depencency on RamlToOpenApiConverter.SourceOnly [bug]
- #1508 Transitive Dependency Conflict with Microsoft.OpenApi in 2.16.0 [bug]
# 2.20.0 (23 October 2026)
- #1516 Add ScribanRegistrationCallback to register custom Scriban functions [feature]
- #1519 Bump MessagePack from 2.5.302 to 2.5.303 [dependencies]
- #1521 Add near-miss diagnostics and request count/unmatched endpoints to the admin API [feature]
- #1522 Downgrade JsonPath.Net from 3.0.2 to 2.2.0 to avoid Open Source Maintenance Fee issues [dependencies]
- #1515 Allow registering custom Scriban functions (ScribanRegistrationCallback), like HandlebarsRegistrationCallback [feature]
- #1520 json-everything sub-dependency now has Open Source Maintenance Fee [dependencies]
The full release notes can be found here: https://github.com/wiremock/WireMock.Net/blob/master/CHANGELOG.md
@@ -20,8 +20,8 @@
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.4.3" />
<!-- CVE-2026-48109 -->
<PackageReference Include="MessagePack" Version="2.5.302" />
<!-- CVE-2026-48109 / CVE-2026-92707 -->
<PackageReference Include="MessagePack" Version="2.5.303" />
</ItemGroup>
<ItemGroup>
@@ -17,8 +17,8 @@
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.4.3" />
<!-- CVE-2026-48109 -->
<PackageReference Include="MessagePack" Version="2.5.302" />
<!-- CVE-2026-48109 / CVE-2026-92707 -->
<PackageReference Include="MessagePack" Version="2.5.303" />
</ItemGroup>
</Project>
+26 -1
View File
@@ -3,6 +3,7 @@
using System.Globalization;
using System.Net;
using Newtonsoft.Json;
using Scriban.Runtime;
using WireMock.Logging;
using WireMock.Matchers;
using WireMock.Models;
@@ -285,11 +286,21 @@ namespace WireMock.Net.ConsoleApplication
var server = WireMockServer.Start(new WireMockServerSettings
{
StartAdminInterface = true,
Logger = new WireMockConsoleLogger(),
Port = 9091
});
server
.WhenRequest(r => r
.WithPath("/test")
.UsingAnyMethod()
)
.ThenRespondWith(r => r
.WithStatusCode(HttpStatusCode.OK)
.WithBody("OK")
);
server
.WhenRequest(r => r
.WithPath("/Content-Length")
@@ -399,12 +410,26 @@ namespace WireMock.Net.ConsoleApplication
// handlebarsContext.RegisterHelper(transformer.Name, transformer.Render); TODO
},
ScribanRegistrationCallback = (scriptObject, fileSystemHandler) =>
{
var globals = (ScriptObject)scriptObject;
globals.Import("greet", new Func<string, string>(name => $"Hello {name}"));
},
// Uncomment below if you want to use the CustomFileSystemFileHandler
// FileSystemHandler = new CustomFileSystemFileHandler()
});
System.Console.WriteLine("WireMockServer listening at {0}", string.Join(",", server.Urls));
server.SetBasicAuthentication("a", "b");
server
.Given(Request.Create().WithPath("/scriban/greet").UsingGet())
.RespondWith(Response.Create()
.WithBody("{{ greet 'WireMock.Net' }}")
.WithTransformer(TransformerType.Scriban)
);
//server.SetAzureADAuthentication(Environment.GetEnvironmentVariable("WIREMOCK_AAD_TENANT")!, "api://e083d51a-01a6-446c-8ad5-0c5c7f002208");
//var http = new HttpClient();
@@ -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; }
}
@@ -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; }
}
@@ -32,7 +32,7 @@ public interface IRequestMessage
string? ProxyUrl { get; set; }
/// <summary>
/// Gets the DateTime.
/// The DateTime when this request was done.
/// </summary>
DateTime DateTime { get; }
@@ -52,7 +52,7 @@ public interface IResponseMessage
object? StatusCode { get; set; }
/// <summary>
/// Gets the DateTime.
/// The DateTime when this response was sent.
/// </summary>
DateTime DateTime { get; }
@@ -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; }
}
@@ -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.
///
@@ -47,8 +47,8 @@
<ItemGroup>
<PackageReference Include="Aspire.Hosting" Version="13.4.3" />
<!-- CVE-2026-48109 -->
<PackageReference Include="MessagePack" Version="2.5.302" />
<!-- CVE-2026-48109 / CVE-2026-92707 -->
<PackageReference Include="MessagePack" Version="2.5.303" />
</ItemGroup>
<ItemGroup>
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>A SystemTextJsonPathMatcher which can be used to match WireMock.Net Requests using JsonPath.Net.</Description>
@@ -35,7 +35,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="JsonPath.Net" Version="3.0.2" />
<!-- Downgraded from 3.0.2 to 2.2.0 to avoid Open Source Maintenance Fee issues -->
<PackageReference Include="JsonPath.Net" Version="2.2.0" />
</ItemGroup>
</Project>
</Project>
@@ -0,0 +1,14 @@
// Copyright © WireMock.Net
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Http;
namespace WireMock.Extensions;
internal static class HttpContextExtensions
{
internal static bool TryGet<T>(this HttpContext httpContext, [NotNullWhen(true)] out T? value)
{
return httpContext.Items.TryGetValue(nameof(T), out value);
}
}
@@ -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$");
@@ -24,6 +24,8 @@ internal interface IAdminPaths
RegexMatcher RequestsGuidPathMatcher { get; }
RegexMatcher RequestsGuidNearMissesPathMatcher { get; }
RegexMatcher ScenariosNameMatcher { get; }
RegexMatcher ScenariosNameWithStateMatcher { get; }
@@ -5,4 +5,6 @@ namespace WireMock.Owin;
internal interface IMappingMatcher
{
(MappingMatcherResult? Match, MappingMatcherResult? Partial) FindBestMatch(RequestMessage request);
IReadOnlyList<MappingMatcherResult> FindNearMisses(IRequestMessage request, int maxResults);
}
@@ -12,7 +12,7 @@ namespace WireMock.Owin.Mappers;
/// <summary>
/// OwinRequestMapper
/// </summary>
internal class OwinRequestMapper : IOwinRequestMapper
internal class OwinRequestMapper(IDateTimeUtils dateTimeUtils) : IOwinRequestMapper
{
/// <inheritdoc />
public async Task<RequestMessage> MapAsync(HttpContext context, IWireMockMiddlewareOptions options)
@@ -72,7 +72,7 @@ internal class OwinRequestMapper : IOwinRequestMapper
await request.HttpContext.Connection.GetClientCertificateAsync()
)
{
DateTime = DateTime.UtcNow
DateTime = dateTimeUtils.UtcNow
};
}
@@ -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}");
@@ -171,6 +171,12 @@ internal class WireMockMiddleware(
}
finally
{
// Set the DateTime from the Response to the current DateTime
if (response is ResponseMessage responseMessage)
{
responseMessage.DateTime = dateTimeUtils.UtcNow;
}
logger.LogRequestAndResponse(logRequest, request, response, result.Match, result.Partial, activity);
try
@@ -255,4 +261,4 @@ internal class WireMockMiddleware(
scenario.Finished = mapping.NextState == null;
});
}
}
}
@@ -73,20 +73,20 @@ internal class WireMockMiddlewareLogger(
var logEntries = options.LogEntries.ToList();
foreach (var logEntry in logEntries
.OrderBy(le => le.RequestMessage?.DateTime ?? le.ResponseMessage?.DateTime)
.OrderBy(le => le.RequestMessage?.DateTime)
.Take(logEntries.Count - options.MaxRequestLogCount.Value))
{
TryRemoveLogEntry(logEntry);
}
}
// In case RequestLogExpirationDuration has a value greater than 0, try to delete existing request logs based on the date.
// In case RequestLogExpirationDuration has a value greater than 0, try to delete existing request logs based on the RequestMessage date.
if (options.RequestLogExpirationDuration is > 0)
{
var logEntries = options.LogEntries.ToList();
var checkTime = dateTimeUtils.UtcNow.AddHours(-options.RequestLogExpirationDuration.Value);
foreach (var logEntry in logEntries.Where(le => le.RequestMessage?.DateTime < checkTime || le.ResponseMessage?.DateTime < checkTime))
foreach (var logEntry in logEntries.Where(le => le.RequestMessage?.DateTime < checkTime))
{
TryRemoveLogEntry(logEntry);
}
@@ -87,7 +87,7 @@ public partial class Response : IResponseBuilder
{
var message = responseMessage ?? new ResponseMessage
{
DateTime = DateTime.UtcNow // We set it here to the current time.
DateTime = DateTime.UtcNow // Initially, this is set to the current time, but will be updated to the real time once the response has been sent.
};
return new Response(message);
@@ -2,7 +2,6 @@
// This source file is based on mock4net by Alexandre Victoor which is licensed under the Apache 2.0 License.
// For more details see 'mock4net/LICENSE.txt' and 'mock4net/readme.md' in this project root.
using System.Linq;
using Stef.Validation;
using WireMock.ResponseBuilders;
using WireMock.Types;
@@ -67,7 +67,8 @@ internal class WebSocketResponseProvider(WebSocketBuilder builder, IGuidUtils gu
builder,
options,
logger,
guidUtils
guidUtils,
dateTimeUtils
);
// Add to registry
@@ -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)
@@ -123,6 +70,74 @@ internal class LogEntryMapper(IWireMockMiddlewareOptions options)
};
}
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)
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http;
using Newtonsoft.Json.Linq;
using Stef.Validation;
using WireMock.Admin.Mappings;
using WireMock.Admin.Requests;
using WireMock.Admin.Scenarios;
using WireMock.Admin.Settings;
using WireMock.Constants;
@@ -93,6 +94,18 @@ public partial class WireMockServer
Given(Request.Create().WithPath(_adminPaths.RequestsGuidPathMatcher).UsingGet()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestGet));
Given(Request.Create().WithPath(_adminPaths.RequestsGuidPathMatcher).UsingDelete()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestDelete));
// __admin/requests/{guid}/near-misses
Given(Request.Create().WithPath(_adminPaths.RequestsGuidNearMissesPathMatcher).UsingGet()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestNearMissesGet));
// __admin/requests/count
Given(Request.Create().WithPath(_adminPaths.Requests + "/count").UsingPost()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestsCount));
// __admin/requests/unmatched
Given(Request.Create().WithPath(_adminPaths.Requests + "/unmatched").UsingGet()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestsUnmatchedGet));
// __admin/requests/unmatched/near-misses
Given(Request.Create().WithPath(_adminPaths.Requests + "/unmatched/near-misses").UsingGet()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestsUnmatchedNearMissesGet));
// __admin/requests/find
Given(Request.Create().WithPath(_adminPaths.Requests + "/find").UsingPost()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestsFind));
Given(Request.Create().WithPath(_adminPaths.Requests + "/find").UsingGet().WithParam("mappingGuid", new NotNullOrEmptyMatcher())).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(RequestsFindByMappingGuid));
@@ -641,6 +654,22 @@ public partial class WireMockServer
#region Requests/find
private IResponseMessage RequestsFind(HttpContext _, IRequestMessage requestMessage)
{
var logEntryMapper = new LogEntryMapper(_options);
var result = FindLogEntriesByRequestModel(requestMessage)
.OrderBy(x => x.Value.AverageTotalScore)
.Select(x => x.Key)
.Select(logEntryMapper.Map);
return ToJson(result);
}
private IResponseMessage RequestsCount(HttpContext _, IRequestMessage requestMessage)
{
return ToJson(new RequestCountModel { Count = FindLogEntriesByRequestModel(requestMessage).Count });
}
private Dictionary<ILogEntry, RequestMatchResult> FindLogEntriesByRequestModel(IRequestMessage requestMessage)
{
var requestModel = DeserializeObject<RequestModel>(requestMessage);
@@ -656,10 +685,7 @@ public partial class WireMockServer
}
}
var logEntryMapper = new LogEntryMapper(_options);
var result = dict.OrderBy(x => x.Value.AverageTotalScore).Select(x => x.Key).Select(logEntryMapper.Map);
return ToJson(result);
return dict;
}
private IResponseMessage RequestsFindByMappingGuid(HttpContext _, IRequestMessage requestMessage)
@@ -679,6 +705,65 @@ public partial class WireMockServer
}
#endregion Requests/find
#region Requests/unmatched
private IResponseMessage RequestsUnmatchedGet(HttpContext _, IRequestMessage requestMessage)
{
var logEntryMapper = new LogEntryMapper(_options);
var result = FindUnmatchedLogEntries().Select(logEntryMapper.Map);
return ToJson(result);
}
private IResponseMessage RequestsUnmatchedNearMissesGet(HttpContext _, IRequestMessage requestMessage)
{
if (!TryGetMaxResultsFromQuery(requestMessage, out var maxResults))
{
return _responseMessageBuilder.Create(HttpStatusCode.BadRequest, "Query parameter 'maxResults' must be a positive integer");
}
var logEntryMapper = new LogEntryMapper(_options);
var result = FindNearMissesForUnmatchedRequests(maxResults).Select(logEntryMapper.Map);
return ToJson(result);
}
#endregion Requests/unmatched
#region Requests/{guid}/near-misses
private IResponseMessage RequestNearMissesGet(HttpContext _, IRequestMessage requestMessage)
{
if (!TryGetMaxResultsFromQuery(requestMessage, out var maxResults))
{
return _responseMessageBuilder.Create(HttpStatusCode.BadRequest, "Query parameter 'maxResults' must be a positive integer");
}
if (TryParseGuidFromSecondToLastSegment(requestMessage, out var guid))
{
var entry = LogEntries.SingleOrDefault(r => !_adminPaths!.Includes(r.RequestMessage?.Path) && r.Guid == guid);
if (entry is not null)
{
var logEntryMapper = new LogEntryMapper(_options);
var result = FindNearMisses(entry, maxResults).Select(logEntryMapper.Map);
return ToJson(result);
}
}
_settings.Logger.Warn("HttpStatusCode set to 404 : Request not found");
return _responseMessageBuilder.Create(HttpStatusCode.NotFound, "Request not found");
}
private static bool TryGetMaxResultsFromQuery(IRequestMessage requestMessage, out int maxResults)
{
maxResults = DefaultMaxNearMisses;
if (requestMessage.Query == null || !requestMessage.Query.TryGetValue("maxResults", out var value))
{
return true;
}
return int.TryParse(value.ToString(), out maxResults) && maxResults > 0;
}
#endregion Requests/{guid}/near-misses
#region Scenarios
private IResponseMessage ScenariosGet(HttpContext _, IRequestMessage requestMessage)
{
@@ -7,6 +7,8 @@ using Stef.Validation;
using WireMock.Logging;
using WireMock.Matchers;
using WireMock.Matchers.Request;
using WireMock.Owin;
using WireMock.Services;
namespace WireMock.Server;
@@ -57,6 +59,68 @@ public partial class WireMockServer
.ToArray();
}
/// <inheritdoc />
[PublicAPI]
public IReadOnlyList<ILogEntry> FindUnmatchedLogEntries()
{
return LogEntries
.Where(le => le.RequestMessage != null && !_options.AdminPaths.Includes(le.RequestMessage.Path))
.Where(le => le.RequestMatchResult is not { IsPerfectMatch: true })
.ToArray();
}
/// <inheritdoc />
[PublicAPI]
public IReadOnlyList<NearMiss> FindNearMisses(IRequestMessage requestMessage, int maxResults = DefaultMaxNearMisses)
{
Guard.NotNull(requestMessage);
Guard.Condition(maxResults, m => m > 0);
return FindNearMisses(requestMessage, null, maxResults);
}
/// <inheritdoc />
[PublicAPI]
public IReadOnlyList<NearMiss> FindNearMisses(ILogEntry logEntry, int maxResults = DefaultMaxNearMisses)
{
Guard.NotNull(logEntry);
Guard.Condition(maxResults, m => m > 0);
return logEntry.RequestMessage is null ? [] : FindNearMisses(logEntry.RequestMessage, logEntry.Guid, maxResults);
}
/// <inheritdoc />
[PublicAPI]
public IReadOnlyList<NearMiss> FindNearMissesForUnmatchedRequests(int maxResultsPerRequest = DefaultMaxNearMisses)
{
Guard.Condition(maxResultsPerRequest, m => m > 0);
return FindUnmatchedLogEntries()
.SelectMany(le => FindNearMisses(le, maxResultsPerRequest))
.ToArray();
}
private const int DefaultMaxNearMisses = 3;
private IMappingMatcher? _nearMissMappingMatcher;
private IReadOnlyList<NearMiss> FindNearMisses(IRequestMessage requestMessage, Guid? logEntryGuid, int maxResults)
{
_nearMissMappingMatcher ??= new MappingMatcher(_options, new RandomizerDoubleBetween0And1());
return _nearMissMappingMatcher
.FindNearMisses(requestMessage, maxResults)
.Select(nm => new NearMiss
{
RequestMessage = requestMessage,
LogEntryGuid = logEntryGuid,
MappingGuid = nm.Mapping.Guid,
MappingTitle = nm.Mapping.Title,
RequestMatchResult = nm.RequestMatchResult
})
.ToArray();
}
/// <inheritdoc cref="IWireMockServer.ResetLogEntries" />
[PublicAPI]
public void ResetLogEntries()
@@ -421,6 +421,105 @@ paths:
schema:
$ref: '#/components/schemas/StatusModel'
/__admin/requests/{guid}/near-misses:
get:
summary: Get the near misses (mappings which partially match) for a logged request
parameters:
- name: guid
in: path
required: true
schema:
type: string
format: uuid
- name: maxResults
in: query
required: false
description: The maximum number of near misses per request (default 3).
schema:
type: integer
format: int32
minimum: 1
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/NearMissModel'
'400':
description: Bad Request
content:
application/json:
schema:
$ref: '#/components/schemas/StatusModel'
'404':
description: Not Found
content:
application/json:
schema:
$ref: '#/components/schemas/StatusModel'
/__admin/requests/count:
post:
summary: Count logged requests matching a request filter
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RequestModel'
required: true
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/RequestCountModel'
x-codegen-request-body-name: requestFilter
/__admin/requests/unmatched:
get:
summary: Get logged requests which did not fully match any mapping
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/LogEntryModel'
/__admin/requests/unmatched/near-misses:
get:
summary: Get the near misses (mappings which partially match) for each logged request which did not fully match any mapping
parameters:
- name: maxResults
in: query
required: false
description: The maximum number of near misses per request (default 3).
schema:
type: integer
format: int32
minimum: 1
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/NearMissModel'
'400':
description: Bad Request
content:
application/json:
schema:
$ref: '#/components/schemas/StatusModel'
/__admin/requests/find:
post:
summary: Find logged requests matching a request filter
@@ -895,6 +994,35 @@ components:
additionalProperties: true
description: Log Request Match Model
NearMissModel:
type: object
properties:
LogEntryGuid:
type: string
description: The unique identifier of the log entry for the request.
format: uuid
Request:
$ref: '#/components/schemas/LogRequestModel'
MappingGuid:
type: string
description: The unique identifier of the mapping.
format: uuid
MappingTitle:
type: string
description: The title of the mapping.
RequestMatchResult:
$ref: '#/components/schemas/LogRequestMatchModel'
description: Near Miss Model, a mapping which did not (fully) match a request
RequestCountModel:
type: object
properties:
Count:
type: integer
description: The number of requests which match the criteria.
format: int32
description: Request Count Model
LogRequestModel:
type: object
properties:
@@ -0,0 +1,40 @@
// Copyright © WireMock.Net
using JetBrains.Annotations;
using Scriban.Runtime;
using Stef.Validation;
using WireMock.Handlers;
namespace WireMock.Settings;
/// <summary>
/// Extension methods for <see cref="WireMockServerSettings"/>.
/// </summary>
public static class WireMockServerSettingsExtensions
{
/// <summary>
/// Adds a strongly typed Scriban registration to <see cref="WireMockServerSettings.ScribanRegistrationCallback"/>.
/// </summary>
/// <remarks>
/// Members added to the <see cref="ScriptObject"/> are available as globals in Scriban and Scriban-DotLiquid templates.
/// When a callback is already configured, it is kept and invoked before <paramref name="registrationCallback"/>.
/// </remarks>
/// <param name="settings">The settings.</param>
/// <param name="registrationCallback">The action which registers functions, objects or variables on the <see cref="ScriptObject"/>.</param>
/// <returns>The same <see cref="WireMockServerSettings"/> instance.</returns>
[PublicAPI]
public static WireMockServerSettings WithScribanRegistration(this WireMockServerSettings settings, Action<ScriptObject, IFileSystemHandler> registrationCallback)
{
Guard.NotNull(settings);
Guard.NotNull(registrationCallback);
var existingCallback = settings.ScribanRegistrationCallback;
settings.ScribanRegistrationCallback = (scriptObject, fileSystemHandler) =>
{
existingCallback?.Invoke(scriptObject, fileSystemHandler);
registrationCallback((ScriptObject)scriptObject, fileSystemHandler);
};
return settings;
}
}
@@ -1,6 +1,7 @@
// Copyright © WireMock.Net
using Scriban;
using Scriban.Runtime;
using Stef.Validation;
using WireMock.Handlers;
using WireMock.Types;
@@ -9,21 +10,40 @@ namespace WireMock.Transformers.Scriban;
internal class ScribanContext : ITransformerContext
{
private static readonly MemberRenamerDelegate MemberRenamer = member => member.Name;
private readonly TransformerType _transformerType;
private readonly ScriptObject? _customGlobals;
public IFileSystemHandler FileSystemHandler { get; }
public ScribanContext(IFileSystemHandler fileSystemHandler, TransformerType transformerType)
public ScribanContext(IFileSystemHandler fileSystemHandler, TransformerType transformerType, ScriptObject? customGlobals = null)
{
FileSystemHandler = Guard.NotNull(fileSystemHandler);
_transformerType = transformerType;
_customGlobals = customGlobals;
}
public string ParseAndRender(string text, object model)
{
var template = _transformerType == TransformerType.ScribanDotLiquid ? Template.ParseLiquid(text) : Template.Parse(text);
var isLiquid = _transformerType == TransformerType.ScribanDotLiquid;
var template = isLiquid ? Template.ParseLiquid(text) : Template.Parse(text);
return template.Render(model, member => member.Name);
if (_customGlobals is null)
{
return template.Render(model, MemberRenamer);
}
// Same as Template.Render(model, MemberRenamer), but with the custom globals pushed beneath the model so that the model takes precedence.
var context = isLiquid ? new LiquidTemplateContext() : new TemplateContext();
context.MemberRenamer = MemberRenamer;
context.PushGlobal(_customGlobals);
var modelGlobals = new ScriptObject();
modelGlobals.Import(model, renamer: MemberRenamer);
context.PushGlobal(modelGlobals);
return template.Render(context);
}
public object? ParseAndEvaluate(string text, object model)
@@ -1,24 +1,32 @@
// Copyright © WireMock.Net
using WireMock.Handlers;
using WireMock.Types;
using Scriban.Runtime;
using Stef.Validation;
using WireMock.Settings;
using WireMock.Types;
namespace WireMock.Transformers.Scriban;
internal class ScribanContextFactory : ITransformerContextFactory
{
private readonly IFileSystemHandler _fileSystemHandler;
private readonly WireMockServerSettings _settings;
private readonly TransformerType _transformerType;
public ScribanContextFactory(IFileSystemHandler fileSystemHandler, TransformerType transformerType)
public ScribanContextFactory(WireMockServerSettings settings, TransformerType transformerType)
{
_fileSystemHandler = Guard.NotNull(fileSystemHandler);
_settings = Guard.NotNull(settings);
_transformerType = Guard.Condition(transformerType, t => t is TransformerType.Scriban or TransformerType.ScribanDotLiquid);
}
public ITransformerContext Create()
{
return new ScribanContext(_fileSystemHandler, _transformerType);
ScriptObject? customGlobals = null;
if (_settings.ScribanRegistrationCallback is { } registrationCallback)
{
customGlobals = new ScriptObject();
registrationCallback(customGlobals, _settings.FileSystemHandler);
}
return new ScribanContext(_settings.FileSystemHandler, _transformerType, customGlobals);
}
}
@@ -19,8 +19,8 @@ internal static class TransformerFactory
case TransformerType.Scriban:
case TransformerType.ScribanDotLiquid:
var factoryDotLiquid = new ScribanContextFactory(settings.FileSystemHandler, transformerType);
return new Transformer(settings, factoryDotLiquid);
var factoryScriban = new ScribanContextFactory(settings, transformerType);
return new Transformer(settings, factoryScriban);
default:
throw new NotSupportedException($"{nameof(TransformerType)} '{transformerType}' is not supported.");
@@ -18,6 +18,8 @@ namespace WireMock.WebSockets;
/// </summary>
public class WireMockWebSocketContext : IWebSocketContext
{
private readonly IDateTimeUtils _dateTimeUtils;
/// <inheritdoc />
public Guid ConnectionId { get; }
@@ -50,7 +52,8 @@ public class WireMockWebSocketContext : IWebSocketContext
WebSocketBuilder builder,
IWireMockMiddlewareOptions options,
IWireMockMiddlewareLogger logger,
IGuidUtils guidUtils
IGuidUtils guidUtils,
IDateTimeUtils dateTimeUtils
)
{
HttpContext = httpContext;
@@ -61,6 +64,7 @@ public class WireMockWebSocketContext : IWebSocketContext
Builder = builder;
Options = options;
Logger = logger;
_dateTimeUtils = dateTimeUtils;
ConnectionId = guidUtils.NewGuid();
}
@@ -169,7 +173,7 @@ public class WireMockWebSocketContext : IWebSocketContext
null
)
{
DateTime = DateTime.UtcNow
DateTime = _dateTimeUtils.UtcNow
};
}
else
@@ -179,7 +183,7 @@ public class WireMockWebSocketContext : IWebSocketContext
{
Method = method,
BodyData = bodyData,
DateTime = DateTime.UtcNow
DateTime = _dateTimeUtils.UtcNow
};
}
@@ -250,6 +250,39 @@ public interface IWireMockAdminApi
[Get("requests/find")]
Task<IList<LogEntryModel>> FindRequestsByMappingGuidAsync([Query] Guid mappingGuid, CancellationToken cancellationToken = default);
/// <summary>
/// Count the requests which match the criteria (<see cref="RequestModel"/>).
/// </summary>
/// <param name="model">The RequestModel</param>
/// <param name="cancellationToken">The optional cancellationToken.</param>
[Post("requests/count")]
[Header("Content-Type", "application/json")]
Task<RequestCountModel> CountRequestsAsync([Body] RequestModel model, CancellationToken cancellationToken = default);
/// <summary>
/// Get the requests which did not fully match any mapping.
/// </summary>
/// <param name="cancellationToken">The optional cancellationToken.</param>
[Get("requests/unmatched")]
Task<IList<LogEntryModel>> GetUnmatchedRequestsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Get the near misses (the mappings which partially match) for all requests which did not fully match any mapping.
/// </summary>
/// <param name="maxResults">The maximum number of near misses per request. Default is 3.</param>
/// <param name="cancellationToken">The optional cancellationToken.</param>
[Get("requests/unmatched/near-misses")]
Task<IList<NearMissModel>> GetNearMissesForUnmatchedRequestsAsync([Query] int? maxResults = null, CancellationToken cancellationToken = default);
/// <summary>
/// Get the near misses (the mappings which partially match) for a request.
/// </summary>
/// <param name="guid">The Guid of the request</param>
/// <param name="maxResults">The maximum number of near misses. Default is 3.</param>
/// <param name="cancellationToken">The optional cancellationToken.</param>
[Get("requests/{guid}/near-misses")]
Task<IList<NearMissModel>> GetNearMissesForRequestAsync([Path] Guid guid, [Query] int? maxResults = null, CancellationToken cancellationToken = default);
/// <summary>
/// Get all scenarios
/// </summary>
@@ -194,6 +194,20 @@ public class WireMockServerSettings
[JsonIgnore]
public Action<IHandlebars, IFileSystemHandler>? HandlebarsRegistrationCallback { get; set; }
/// <summary>
/// Action which can be used to add additional Scriban registrations (functions, objects or variables). [Optional]
/// </summary>
/// <remarks>
/// The first argument is a <c>Scriban.Runtime.ScriptObject</c> from the <c>Scriban.Signed</c> package.
/// It is typed as <see cref="object"/> so that this package does not depend on Scriban.
/// Members added to it are available as globals in <see cref="TransformerType.Scriban"/> and <see cref="TransformerType.ScribanDotLiquid"/> templates.
/// On a name clash the template model (<c>request</c>, <c>response</c>, <c>mapping</c> and <c>data</c>) takes precedence.
/// The action is invoked each time a transformer context is created.
/// </remarks>
[PublicAPI]
[JsonIgnore]
public Action<object, IFileSystemHandler>? ScribanRegistrationCallback { get; set; }
/// <summary>
/// Allow the usage of CSharpCodeMatcher (default is not allowed).
/// </summary>
@@ -21,8 +21,8 @@
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.4.3" />
<!-- CVE-2026-48109 -->
<PackageReference Include="MessagePack" Version="2.5.302" />
<!-- CVE-2026-48109 / CVE-2026-92707 -->
<PackageReference Include="MessagePack" Version="2.5.303" />
</ItemGroup>
<ItemGroup>
@@ -33,8 +33,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- CVE-2026-48109 -->
<PackageReference Include="MessagePack" Version="2.5.302" />
<!-- CVE-2026-48109 / CVE-2026-92707 -->
<PackageReference Include="MessagePack" Version="2.5.303" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,230 @@
// Copyright © WireMock.Net
using System.Net;
using System.Net.Http;
using RestEase;
using WireMock.Admin.Mappings;
using WireMock.Client;
using WireMock.Logging;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using WireMock.Settings;
namespace WireMock.Net.Tests.AdminApi;
public partial class WireMockAdminApiTests
{
private static readonly Guid OrdersPostMappingGuid = new("22222222-0000-0000-0000-000000000001");
private static readonly Guid OrdersGetMappingGuid = new("22222222-0000-0000-0000-000000000002");
[Fact]
public async Task IWireMockAdminApi_CountRequestsAsync()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
await httpClient.GetAsync("/orders", cancellationToken);
await httpClient.PostAsync("/orders", new StringContent("{}"), cancellationToken);
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var getCount = await api.CountRequestsAsync(new RequestModel { Methods = ["GET"] }, cancellationToken);
var deleteCount = await api.CountRequestsAsync(new RequestModel { Methods = ["DELETE"] }, cancellationToken);
// Assert
getCount.Count.Should().Be(2);
deleteCount.Count.Should().Be(0);
}
[Fact]
public async Task IWireMockAdminApi_GetUnmatchedRequestsAsync()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
await httpClient.GetAsync("/unknown", cancellationToken);
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var requests = await api.GetUnmatchedRequestsAsync(cancellationToken);
// Assert
requests.Should().ContainSingle();
requests[0].Request!.Path.Should().Be("/unknown");
requests[0].MappingGuid.Should().BeNull();
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForUnmatchedRequestsAsync()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.PutAsync("/orders", new StringContent("{}"), cancellationToken);
var logEntryGuid = server.LogEntries.Single().Guid;
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var nearMisses = await api.GetNearMissesForUnmatchedRequestsAsync(cancellationToken: cancellationToken);
// Assert
nearMisses.Should().HaveCount(2);
nearMisses.Select(nm => nm.MappingGuid).Should().BeEquivalentTo([OrdersPostMappingGuid, OrdersGetMappingGuid]);
nearMisses.Should().AllSatisfy(nm =>
{
nm.LogEntryGuid.Should().Be(logEntryGuid);
nm.Request!.Method.Should().Be("PUT");
nm.Request.Path.Should().Be("/orders");
nm.RequestMatchResult!.IsPerfectMatch.Should().BeFalse();
nm.RequestMatchResult.AverageTotalScore.Should().BeApproximately(0.5, 0.001);
});
nearMisses.Select(nm => nm.MappingTitle).Should().Contain("Create order");
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForUnmatchedRequestsAsync_WithMaxResults()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.PutAsync("/orders", new StringContent("{}"), cancellationToken);
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var nearMisses = await api.GetNearMissesForUnmatchedRequestsAsync(1, cancellationToken);
// Assert
nearMisses.Should().ContainSingle();
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForUnmatchedRequestsAsync_WithInvalidMaxResults_ReturnsBadRequest()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
// Act
var response = await httpClient.GetAsync("/__admin/requests/unmatched/near-misses?maxResults=0", cancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForRequestAsync_ShouldExcludeMatchedMapping()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
var logEntry = server.LogEntries.Single();
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var nearMisses = await api.GetNearMissesForRequestAsync(logEntry.Guid, cancellationToken: cancellationToken);
// Assert
logEntry.MappingGuid.Should().Be(OrdersGetMappingGuid);
nearMisses.Should().ContainSingle();
nearMisses[0].MappingGuid.Should().Be(OrdersPostMappingGuid);
nearMisses[0].LogEntryGuid.Should().Be(logEntry.Guid);
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForRequestAsync_UnknownRequest_ReturnsNotFound()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
Func<Task> act = () => api.GetNearMissesForRequestAsync(Guid.NewGuid(), cancellationToken: cancellationToken);
// Assert
(await act.Should().ThrowAsync<ApiException>()).Which.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task IWireMockAdminApi_GetNearMissesForRequestAsync_WithMultiplePerfectMatches_ShouldNotReturnPerfectMatches()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
server
.Given(Request.Create().WithPath("/orders").UsingGet())
.AtPriority(100)
.WithTitle("Get orders (fallback)")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
var logEntry = server.LogEntries.Single();
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var nearMisses = await api.GetNearMissesForRequestAsync(logEntry.Guid, cancellationToken: cancellationToken);
// Assert
nearMisses.Should().ContainSingle();
nearMisses[0].MappingGuid.Should().Be(OrdersPostMappingGuid);
}
[Fact]
public async Task IWireMockAdminApi_CountRequestsAsync_ShouldNotCountAdminRequests()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithOrdersMappings();
using var httpClient = server.CreateClient();
await httpClient.GetAsync("/orders", cancellationToken);
await httpClient.GetAsync("/__admin/mappings", cancellationToken);
var api = RestClient.For<IWireMockAdminApi>(server.Url!);
// Act
var count = await api.CountRequestsAsync(new RequestModel { Methods = ["GET"] }, cancellationToken);
// Assert
count.Count.Should().Be(1);
}
private static WireMockServer StartServerWithOrdersMappings()
{
var server = WireMockServer.Start(new WireMockServerSettings
{
StartAdminInterface = true,
Logger = new WireMockNullLogger()
});
server
.Given(Request.Create().WithPath("/orders").UsingPost())
.WithGuid(OrdersPostMappingGuid)
.WithTitle("Create order")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.Created));
server
.Given(Request.Create().WithPath("/orders").UsingGet())
.WithGuid(OrdersGetMappingGuid)
.WithTitle("Get orders")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
return server;
}
}
+1 -1
View File
@@ -6,5 +6,5 @@ internal static class Constants
{
internal const int NumStaticMappings = 10;
internal const int NumAdminMappings = 39;
internal const int NumAdminMappings = 43;
}
@@ -0,0 +1,29 @@
// Copyright © WireMock.Net
#if NET6_0_OR_GREATER
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using WireMock.Net.Testcontainers.Utils;
namespace WireMock.Net.Tests.Facts;
[ExcludeFromCodeCoverage]
public sealed class RunOnDockerFact : FactAttribute
{
public RunOnDockerFact(
[CallerFilePath] string? sourceFilePath = null,
[CallerLineNumber] int sourceLineNumber = -1) : base(sourceFilePath, sourceLineNumber)
{
OSPlatform currentPlatform;
try
{
currentPlatform = TestcontainersUtils.GetImageOSAsync.Value.Result;
}
catch
{
Skip = $"Only run test when Docker is installed.";
}
}
}
#endif
@@ -15,7 +15,19 @@ public sealed class RunOnDockerPlatformFact : FactAttribute
[CallerFilePath] string? sourceFilePath = null,
[CallerLineNumber] int sourceLineNumber = -1) : base(sourceFilePath, sourceLineNumber)
{
if (TestcontainersUtils.GetImageOSAsync.Value.Result != OSPlatform.Create(platform))
OSPlatform currentPlatform;
try
{
currentPlatform = TestcontainersUtils.GetImageOSAsync.Value.Result;
}
catch
{
Skip = $"Only run test when Docker is installed.";
return;
}
if (currentPlatform != OSPlatform.Create(platform))
{
Skip = $"Only run test when Docker OS Platform {platform} is used.";
}
@@ -259,6 +259,96 @@ public class MappingMatcherTests
mappingMock.Verify(m => m.GetRequestMatchResult(It.IsAny<RequestMessage>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public void MappingMatcher_FindNearMisses_ShouldExcludePerfectMatchesAndMismatches_AndOrderByBestMatchFirst()
{
// Assign
var perfect = Guid.Parse("00000000-0000-0000-0000-000000000001");
var mismatch = Guid.Parse("00000000-0000-0000-0000-000000000002");
var low = Guid.Parse("00000000-0000-0000-0000-000000000003");
var high = Guid.Parse("00000000-0000-0000-0000-000000000004");
var mappings = InitMappings
(
(perfect, [1.0], null),
(mismatch, [0.0], null),
(low, [0.4], null),
(high, [0.8], null)
);
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
// Act
var result = _sut.FindNearMisses(request, 10);
// Assert
result.Select(r => r.Mapping.Guid).Should().Equal(high, low);
}
[Fact]
public void MappingMatcher_FindNearMisses_ShouldRespectMaxResults()
{
// Assign
var low = Guid.Parse("00000000-0000-0000-0000-000000000001");
var high = Guid.Parse("00000000-0000-0000-0000-000000000002");
var mappings = InitMappings
(
(low, [0.4], null),
(high, [0.8], null)
);
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
// Act
var result = _sut.FindNearMisses(request, 1);
// Assert
result.Should().ContainSingle().Which.Mapping.Guid.Should().Be(high);
}
[Fact]
public void MappingMatcher_FindNearMisses_ShouldIgnoreProbability()
{
// Assign
var withProbability = Guid.Parse("00000000-0000-0000-0000-000000000001");
var mappings = InitMappings
(
(withProbability, [0.5], 0.1)
);
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
// Act
var result = _sut.FindNearMisses(request, 3);
// Assert
result.Should().ContainSingle().Which.Mapping.Guid.Should().Be(withProbability);
_randomizerDoubleBetween0And1Mock.Verify(r => r.Generate(), Times.Never);
}
[Fact]
public void MappingMatcher_FindNearMisses_WhenMappingThrowsException_ShouldSkipMapping()
{
// Assign
var mappingMock = new Mock<IMapping>();
mappingMock.SetupGet(m => m.IsDisabled).Returns(false);
mappingMock.Setup(m => m.GetRequestMatchResult(It.IsAny<IRequestMessage>(), It.IsAny<string>())).Throws<Exception>();
var mappings = new ConcurrentDictionary<Guid, IMapping>();
mappings.TryAdd(Guid.NewGuid(), mappingMock.Object);
_optionsMock.Setup(o => o.Mappings).Returns(mappings);
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1");
// Act
var result = _sut.FindNearMisses(request, 3);
// Assert
result.Should().BeEmpty();
}
private static ConcurrentDictionary<Guid, IMapping> InitMappings(params (Guid guid, double[] scores, double? probability)[] matches)
{
var mappings = new ConcurrentDictionary<Guid, IMapping>();
@@ -106,6 +106,15 @@ public class WireMockMiddlewareTests
[Fact]
public async Task WireMockMiddleware_Invoke_NoMatch()
{
// Arrange
var inititalDateTime = new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var responseDateTime = new DateTime(2027, 1, 1, 0, 0, 42, DateTimeKind.Utc);
_dateTimeUtilsMock.Reset();
_dateTimeUtilsMock.SetupSequence(d => d.UtcNow)
.Returns(inititalDateTime)
.Returns(responseDateTime);
// Act
await _sut.Invoke(_contextMock.Object);
@@ -115,7 +124,7 @@ public class WireMockMiddlewareTests
Expression<Func<ResponseMessage, bool>> match = r =>
(int)r.StatusCode! == 404 &&
((StatusModel)r.BodyData!.BodyAsJson!).Status == "No matching mapping found" &&
r.DateTime == UtcNow;
r.DateTime == responseDateTime;
_responseMapperMock.Verify(m => m.MapAsync(It.Is(match), It.IsAny<HttpResponse>()), Times.Once);
}
@@ -8,6 +8,8 @@ using WireMock.Util;
using WireMock.Handlers;
using Moq;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
using Scriban.Runtime;
namespace WireMock.Net.Tests.ResponseBuilders;
@@ -65,5 +67,50 @@ public class ResponseWithScribanTests
// Assert
response.Message.BodyData.BodyAsString.Should().Be("test http://localhost/foo /foo POSt");
}
[Theory]
[InlineData(TransformerType.Scriban)]
[InlineData(TransformerType.ScribanDotLiquid)]
public async Task Response_ProvideResponse_WithScribanRegistrationCallback_UsesCustomFunctionInBodyAndHeaders(TransformerType transformerType)
{
// Arrange
_settings.ScribanRegistrationCallback = (scriptObject, _) =>
((ScriptObject)scriptObject).Import("fake_name", new Func<string>(() => "Jane Doe"));
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", ClientIp);
var responseBuilder = Response.Create()
.WithBody("{{ fake_name }} {{ request.Path }}")
.WithHeader("x-name", "{{ fake_name }}")
.WithTransformer(transformerType);
// Act
var response = await responseBuilder.ProvideResponseAsync(_mappingMock.Object, Mock.Of<HttpContext>(), request, _settings);
// Assert
response.Message.BodyData!.BodyAsString.Should().Be("Jane Doe /foo");
response.Message.Headers!["x-name"].Should().ContainSingle().Which.Should().Be("Jane Doe");
}
[Theory]
[InlineData(TransformerType.Scriban)]
[InlineData(TransformerType.ScribanDotLiquid)]
public async Task Response_ProvideResponse_WithScribanRegistrationCallback_UsesCustomFunctionInBodyAsJson(TransformerType transformerType)
{
// Arrange
_settings.WithScribanRegistration((scriptObject, _) => scriptObject.Import("fake_name", new Func<string>(() => "Jane Doe")));
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", ClientIp);
var responseBuilder = Response.Create()
.WithBodyAsJson(new { name = "{{ fake_name }}", path = "{{ request.Path }}" })
.WithTransformer(transformerType);
// Act
var response = await responseBuilder.ProvideResponseAsync(_mappingMock.Object, Mock.Of<HttpContext>(), request, _settings);
// Assert
JsonConvert.SerializeObject(response.Message.BodyData!.BodyAsJson).Should().Be("{\"name\":\"Jane Doe\",\"path\":\"/foo\"}");
}
}
@@ -0,0 +1,82 @@
// Copyright © WireMock.Net
using Moq;
using Scriban.Runtime;
using WireMock.Handlers;
using WireMock.Settings;
namespace WireMock.Net.Tests.Settings;
public class WireMockServerSettingsExtensionsTests
{
private readonly IFileSystemHandler _fileSystemHandler = Mock.Of<IFileSystemHandler>();
[Fact]
public void WithScribanRegistration_SetsCallbackWhichPassesTypedScriptObjectAndFileSystemHandler()
{
// Arrange
ScriptObject? receivedScriptObject = null;
IFileSystemHandler? receivedFileSystemHandler = null;
var settings = new WireMockServerSettings();
var scriptObject = new ScriptObject();
// Act
var result = settings.WithScribanRegistration((so, fs) =>
{
receivedScriptObject = so;
receivedFileSystemHandler = fs;
});
settings.ScribanRegistrationCallback!(scriptObject, _fileSystemHandler);
// Assert
result.Should().BeSameAs(settings);
receivedScriptObject.Should().BeSameAs(scriptObject);
receivedFileSystemHandler.Should().BeSameAs(_fileSystemHandler);
}
[Fact]
public void WithScribanRegistration_WithExistingCallback_InvokesBothInRegistrationOrder()
{
// Arrange
var calls = new List<string>();
var settings = new WireMockServerSettings
{
ScribanRegistrationCallback = (_, _) => calls.Add("existing")
};
// Act
settings
.WithScribanRegistration((_, _) => calls.Add("first"))
.WithScribanRegistration((_, _) => calls.Add("second"));
settings.ScribanRegistrationCallback!(new ScriptObject(), _fileSystemHandler);
// Assert
calls.Should().Equal("existing", "first", "second");
}
[Fact]
public void WithScribanRegistration_WithNullSettings_ThrowsArgumentNullException()
{
// Arrange
WireMockServerSettings settings = null!;
// Act
Action act = () => settings.WithScribanRegistration((_, _) => { });
// Assert
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void WithScribanRegistration_WithNullCallback_ThrowsArgumentNullException()
{
// Arrange
var settings = new WireMockServerSettings();
// Act
Action act = () => settings.WithScribanRegistration(null!);
// Assert
act.Should().Throw<ArgumentNullException>();
}
}
@@ -442,8 +442,9 @@ public class StatefulBehaviorTests
// Act: fire request 1 but don't await it yet — it will sit in a 500 ms delay
var request1Task = client.GetStringAsync(server.Url + path, cancellationToken);
// Give the server a moment to match & transition state before the delay completes
await Task.Delay(100, cancellationToken);
// Wait until the server has matched request 1 & transitioned the state; request 1 must still be delayed
await WaitForScenarioStateAsync(server, "1260", "State1", cancellationToken);
request1Task.IsCompleted.Should().BeFalse();
// Request 2 is sent while request 1 is still being delayed.
// After the fix the state has already transitioned, so request 2 matches Mapping 2.
@@ -481,7 +482,8 @@ public class StatefulBehaviorTests
// Act
var request1Task = client.GetStringAsync(server.Url + path, cancellationToken);
await Task.Delay(100, cancellationToken);
await WaitForScenarioStateAsync(server, "s", "State1", cancellationToken);
request1Task.IsCompleted.Should().BeFalse();
var response2 = await client.GetStringAsync(server.Url + path, cancellationToken);
var response1 = await request1Task;
@@ -574,5 +576,20 @@ public class StatefulBehaviorTests
server.Stop();
}
/// <summary>
/// Polls the scenario state instead of using a fixed delay, so tests do not depend on how fast the server handles the first request
/// (a cold server can take longer than a fixed delay to match the request and transition the state).
/// </summary>
private static async Task WaitForScenarioStateAsync(WireMockServer server, string scenario, string expectedState, CancellationToken cancellationToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
while (server.Scenarios.FirstOrDefault(s => s.Name == scenario)?.NextState != expectedState)
{
await Task.Delay(10, timeout.Token);
}
}
}
@@ -21,7 +21,7 @@ public class TestcontainersTests(ITestOutputHelper testOutputHelper)
TimestampFormat = "yyy-MM-dd HH:mm:ss.fff"
});
[Fact]
[RunOnDockerFact]
public async Task WireMockContainer_Build_And_StartAsync_and_StopAsync()
{
// Act
@@ -57,7 +57,7 @@ public class TestcontainersTests(ITestOutputHelper testOutputHelper)
await StopAsync(wireMockContainer);
}
[Fact]
[RunOnDockerFact]
public async Task WireMockContainer_Build_WithImage_And_StartAsync_and_StopAsync()
{
// Arrange
@@ -83,7 +83,7 @@ public class TestcontainersTests(ITestOutputHelper testOutputHelper)
await StopAsync(wireMockContainer);
}
[Fact]
[RunOnDockerFact]
public async Task WireMockContainer_Build_WithImageAsText_And_StartAsync_and_StopAsync()
{
// Arrange
@@ -10,6 +10,7 @@ using Meziantou.Extensions.Logging.Xunit.v3;
using Microsoft.Extensions.Logging;
using WireMock.Constants;
using WireMock.Net.Testcontainers;
using WireMock.Net.Tests.Facts;
using WireMock.Util;
namespace WireMock.Net.Tests.Testcontainers;
@@ -25,7 +26,7 @@ public class TestcontainersTestsGrpc(ITestOutputHelper testOutputHelper)
TimestampFormat = "yyy-MM-dd HH:mm:ss.fff"
});
[Fact]
[RunOnDockerFact]
public async Task WireMockContainer_Build_Grpc_TestPortsAndUrls1()
{
// Arrange
@@ -82,7 +83,7 @@ public class TestcontainersTestsGrpc(ITestOutputHelper testOutputHelper)
}
}
[Fact]
[RunOnDockerFact]
public async Task WireMockContainer_Build_Grpc_TestPortsAndUrls2()
{
// Arrange
@@ -136,7 +137,7 @@ public class TestcontainersTestsGrpc(ITestOutputHelper testOutputHelper)
}
}
[Fact]
[RunOnDockerFact]
public async Task WireMockContainer_Build_Grpc_ProtoDefinitionFromJson_UsingGrpcGeneratedClient()
{
var wireMockContainer = await Given_WireMockContainerIsStartedForHttpAndGrpcAsync();
@@ -150,7 +151,7 @@ public class TestcontainersTestsGrpc(ITestOutputHelper testOutputHelper)
await StopAsync(wireMockContainer);
}
[Fact]
[RunOnDockerFact]
public async Task WireMockContainer_Build_Grpc_ProtoDefinitionAtServerLevel_UsingGrpcGeneratedClient()
{
var wireMockContainer = await Given_WireMockContainerWithProtoDefinitionAtServerLevelIsStartedForHttpAndGrpcAsync();
@@ -164,7 +165,7 @@ public class TestcontainersTestsGrpc(ITestOutputHelper testOutputHelper)
await StopAsync(wireMockContainer);
}
[Fact]
[RunOnDockerFact]
public async Task WireMockContainer_Build_Grpc_ProtoDefinitionAtServerLevel_UsingGrpcGeneratedClient_AndWithWatchStaticMappings()
{
var wireMockContainer = await Given_WireMockContainerWithProtoDefinitionAtServerLevelWithWatchStaticMappingsIsStartedForHttpAndGrpcAsync();
@@ -1,7 +1,9 @@
// Copyright © WireMock.Net
using Moq;
using Scriban.Runtime;
using WireMock.Handlers;
using WireMock.Settings;
using WireMock.Transformers.Scriban;
using WireMock.Types;
@@ -17,23 +19,79 @@ namespace WireMock.Net.Tests.Transformers.Scriban
public void Create_With_Scriban_TransformerType_Creates_ITransformerContext(TransformerType transformerType)
{
// Arrange
var sut = new ScribanContextFactory(_fileSystemHandlerMock.Object, transformerType);
var settings = new WireMockServerSettings
{
FileSystemHandler = _fileSystemHandlerMock.Object
};
var sut = new ScribanContextFactory(settings, transformerType);
// Act
var result = sut.Create();
// Assert
result.Should().NotBeNull();
result.FileSystemHandler.Should().BeSameAs(_fileSystemHandlerMock.Object);
}
[Fact]
public void Create_With_Invalid_TransformerType_Throws_Exception()
{
// Arrange
var settings = new WireMockServerSettings
{
FileSystemHandler = _fileSystemHandlerMock.Object
};
// Act
Action action = () => new ScribanContextFactory(_fileSystemHandlerMock.Object, TransformerType.Handlebars);
Action action = () => new ScribanContextFactory(settings, TransformerType.Handlebars);
// Assert
action.Should().Throw<Exception>();
}
[Theory]
[InlineData(TransformerType.Scriban)]
[InlineData(TransformerType.ScribanDotLiquid)]
public void Create_WithScribanRegistrationCallback_InvokesCallbackOnceWithScriptObjectAndFileSystemHandler(TransformerType transformerType)
{
// Arrange
var calls = new List<(object ScriptObject, IFileSystemHandler FileSystemHandler)>();
var settings = new WireMockServerSettings
{
FileSystemHandler = _fileSystemHandlerMock.Object,
ScribanRegistrationCallback = (scriptObject, fileSystemHandler) => calls.Add((scriptObject, fileSystemHandler))
};
var sut = new ScribanContextFactory(settings, transformerType);
// Act
var result = sut.Create();
// Assert
result.Should().NotBeNull();
calls.Should().ContainSingle();
calls[0].ScriptObject.Should().BeOfType<ScriptObject>();
calls[0].FileSystemHandler.Should().BeSameAs(_fileSystemHandlerMock.Object);
}
[Fact]
public void Create_CalledTwice_InvokesScribanRegistrationCallbackWithNewScriptObjectEachTime()
{
// Arrange
var scriptObjects = new List<object>();
var settings = new WireMockServerSettings
{
FileSystemHandler = _fileSystemHandlerMock.Object,
ScribanRegistrationCallback = (scriptObject, _) => scriptObjects.Add(scriptObject)
};
var sut = new ScribanContextFactory(settings, TransformerType.Scriban);
// Act
sut.Create();
sut.Create();
// Assert
scriptObjects.Should().HaveCount(2);
scriptObjects[0].Should().NotBeSameAs(scriptObjects[1]);
}
}
}
}
@@ -0,0 +1,165 @@
// Copyright © WireMock.Net
using Moq;
using Scriban.Runtime;
using WireMock.Handlers;
using WireMock.Models;
using WireMock.Transformers;
using WireMock.Transformers.Scriban;
using WireMock.Types;
using WireMock.Util;
namespace WireMock.Net.Tests.Transformers.Scriban;
public class ScribanContextTests
{
private readonly IFileSystemHandler _fileSystemHandler = Mock.Of<IFileSystemHandler>();
[Theory]
[InlineData(TransformerType.Scriban)]
[InlineData(TransformerType.ScribanDotLiquid)]
public void ParseAndRender_WithCustomFunction_RendersFunctionResult(TransformerType transformerType)
{
// Arrange
var customGlobals = new ScriptObject();
customGlobals.Import("fake_name", new Func<string>(() => "Jane Doe"));
var sut = new ScribanContext(_fileSystemHandler, transformerType, customGlobals);
// Act
var result = sut.ParseAndRender("Hello {{ fake_name }} at {{ request.Path }}", CreateModel());
// Assert
result.Should().Be("Hello Jane Doe at /foo/bar");
}
[Fact]
public void ParseAndRender_Scriban_WithCustomFunctionWithArgument_RendersFunctionResult()
{
// Arrange
var customGlobals = new ScriptObject();
customGlobals.Import("greet", new Func<string, string>(name => $"Hello {name}"));
var sut = new ScribanContext(_fileSystemHandler, TransformerType.Scriban, customGlobals);
// Act
var result = sut.ParseAndRender("{{ greet request.Method }}", CreateModel());
// Assert
result.Should().Be("Hello GET");
}
[Fact]
public void ParseAndRender_ScribanDotLiquid_WithCustomFunctionAsFilter_RendersFunctionResult()
{
// Arrange
var customGlobals = new ScriptObject();
customGlobals.Import("greet", new Func<string, string>(name => $"Hello {name}"));
var sut = new ScribanContext(_fileSystemHandler, TransformerType.ScribanDotLiquid, customGlobals);
// Act
var result = sut.ParseAndRender("{{ request.Method | greet }}", CreateModel());
// Assert
result.Should().Be("Hello GET");
}
[Theory]
[InlineData(TransformerType.Scriban)]
[InlineData(TransformerType.ScribanDotLiquid)]
public void ParseAndRender_WithCustomGlobalNamedLikeModelMember_ModelTakesPrecedence(TransformerType transformerType)
{
// Arrange
var customGlobals = new ScriptObject();
customGlobals.SetValue("request", "custom", false);
customGlobals.SetValue("other", "custom", false);
var sut = new ScribanContext(_fileSystemHandler, transformerType, customGlobals);
// Act
var result = sut.ParseAndRender("{{ request.Path }} {{ other }}", CreateModel());
// Assert
result.Should().Be("/foo/bar custom");
}
[Theory]
[InlineData(TransformerType.Scriban)]
[InlineData(TransformerType.ScribanDotLiquid)]
public void ParseAndRender_WithCustomGlobalNamedLikeBuiltin_CustomGlobalTakesPrecedence(TransformerType transformerType)
{
// Arrange
var customGlobals = new ScriptObject();
customGlobals.SetValue("date", "custom date", false);
var sut = new ScribanContext(_fileSystemHandler, transformerType, customGlobals);
// Act
var result = sut.ParseAndRender("{{ date }}", CreateModel());
// Assert
result.Should().Be("custom date");
}
[Fact]
public void ParseAndRender_WithTemplateAssignment_DoesNotMutateCustomGlobals()
{
// Arrange
var customGlobals = new ScriptObject();
customGlobals.SetValue("x", "custom", false);
var sut = new ScribanContext(_fileSystemHandler, TransformerType.Scriban, customGlobals);
// Act
var first = sut.ParseAndRender("{{ x = 'assigned'; x }} {{ y = 1; y }}", CreateModel());
var second = sut.ParseAndRender("{{ x }}", CreateModel());
// Assert
first.Should().Be("assigned 1");
second.Should().Be("custom");
customGlobals.Keys.Should().BeEquivalentTo("x");
customGlobals["x"].Should().Be("custom");
}
[Theory]
[InlineData(TransformerType.Scriban, "{{ request.Url }} {{ request.Path }} {{ request.Method }}")]
[InlineData(TransformerType.Scriban, "{{ request.PathSegments[1] }} {{ request.Headers.Accept }}")]
[InlineData(TransformerType.Scriban, "{{ for s in request.PathSegments }}[{{ s }}]{{ end }}")]
[InlineData(TransformerType.Scriban, "{{ request.Body | string.upcase }} {{ data.Id }}")]
[InlineData(TransformerType.Scriban, "{{ x = 5; x * 2 }}")]
[InlineData(TransformerType.ScribanDotLiquid, "{{ request.Url }} {{ request.Path }} {{ request.Method }}")]
[InlineData(TransformerType.ScribanDotLiquid, "{% for s in request.PathSegments %}[{{ s }}]{% endfor %}")]
[InlineData(TransformerType.ScribanDotLiquid, "{{ request.Body | upcase }} {{ data.Id }}")]
[InlineData(TransformerType.ScribanDotLiquid, "{% assign x = 5 %}{{ x | times: 2 }}")]
public void ParseAndRender_WithEmptyCustomGlobals_RendersSameAsWithoutCustomGlobals(TransformerType transformerType, string template)
{
// Arrange
var model = CreateModel();
var withoutCustomGlobals = new ScribanContext(_fileSystemHandler, transformerType);
var withCustomGlobals = new ScribanContext(_fileSystemHandler, transformerType, new ScriptObject());
// Act
var expected = withoutCustomGlobals.ParseAndRender(template, model);
var result = withCustomGlobals.ParseAndRender(template, model);
// Assert
expected.Should().NotBeNullOrWhiteSpace();
result.Should().Be(expected);
}
private static TransformModel CreateModel()
{
var body = new BodyData
{
BodyAsString = "hello",
DetectedBodyType = BodyType.String
};
var headers = new Dictionary<string, string[]>
{
{ "Accept", ["application/json"] }
};
var request = new RequestMessage(new UrlDetails("http://localhost/foo/bar"), "GET", "::1", body, headers);
return new TransformModel
{
mapping = Mock.Of<IMapping>(),
request = request,
data = new { Id = 42 }
};
}
}
@@ -18,6 +18,7 @@ using WireMock.Util;
namespace WireMock.Net.Tests;
[Collection(nameof(WireMockServerProxyTests))]
public class WireMockServerProxyTests
{
private readonly CancellationToken _ct = TestContext.Current.CancellationToken;
@@ -58,12 +59,13 @@ public class WireMockServerProxyTests
public async Task WireMockServer_Proxy_AdminFalse_With_SaveMapping_Is_True_And_SaveMappingToFile_Is_False_Should_AddInternalMappingOnly()
{
// Assign
using var target = StartProxyTargetServer();
var cancellationToken = TestContext.Current.CancellationToken;
var settings = new WireMockServerSettings
{
ProxyAndRecordSettings = new ProxyAndRecordSettings
{
Url = "http://www.google.com",
Url = target.Url!,
SaveMapping = true,
SaveMappingToFile = false,
ExcludedHeaders = ["Connection"] // Needed for .NET 4.5.x and 4.6.x
@@ -92,12 +94,13 @@ public class WireMockServerProxyTests
public async Task WireMockServer_Proxy_AdminTrue_With_SaveMapping_Is_True_And_SaveMappingToFile_Is_False_Should_AddInternalMappingOnly()
{
// Assign
using var target = StartProxyTargetServer();
var cancellationToken = TestContext.Current.CancellationToken;
var settings = new WireMockServerSettings
{
ProxyAndRecordSettings = new ProxyAndRecordSettings
{
Url = "http://www.google.com",
Url = target.Url!,
SaveMapping = true,
SaveMappingToFile = false,
ExcludedHeaders = ["Connection"] // Needed for .NET 4.5.x and 4.6.x
@@ -184,6 +187,7 @@ public class WireMockServerProxyTests
public async Task WireMockServer_Proxy_With_SaveMapping_Is_False_And_SaveMappingToFile_Is_True_ShouldSaveMappingToFile()
{
// Assign
using var target = StartProxyTargetServer();
var fileSystemHandlerMock = new Mock<IFileSystemHandler>();
fileSystemHandlerMock.Setup(f => f.GetMappingFolder()).Returns("m");
@@ -191,7 +195,7 @@ public class WireMockServerProxyTests
{
ProxyAndRecordSettings = new ProxyAndRecordSettings
{
Url = "http://www.google.com",
Url = target.Url!,
SaveMapping = false,
SaveMappingToFile = true
},
@@ -220,6 +224,7 @@ public class WireMockServerProxyTests
public async Task WireMockServer_Proxy_With_SaveMappingForStatusCodePattern_Is_False_Should_Not_SaveMapping()
{
// Assign
using var target = StartProxyTargetServer();
var fileSystemHandlerMock = new Mock<IFileSystemHandler>();
fileSystemHandlerMock.Setup(f => f.GetMappingFolder()).Returns("m");
@@ -227,7 +232,7 @@ public class WireMockServerProxyTests
{
ProxyAndRecordSettings = new ProxyAndRecordSettings
{
Url = "http://www.google.com",
Url = target.Url!,
SaveMapping = true,
SaveMappingToFile = true,
SaveMappingForStatusCodePattern = "999" // Just make sure that we don't want this mapping
@@ -257,6 +262,7 @@ public class WireMockServerProxyTests
public async Task WireMockServer_Proxy_With_DoNotSaveMappingForHttpMethod_Should_Not_SaveMapping()
{
// Assign
using var target = StartProxyTargetServer();
var fileSystemHandlerMock = new Mock<IFileSystemHandler>();
fileSystemHandlerMock.Setup(f => f.GetMappingFolder()).Returns("m");
@@ -264,7 +270,7 @@ public class WireMockServerProxyTests
{
ProxyAndRecordSettings = new ProxyAndRecordSettings
{
Url = "http://www.google.com",
Url = target.Url!,
SaveMapping = true,
SaveMappingToFile = true,
SaveMappingSettings = new ProxySaveMappingSettings
@@ -297,11 +303,12 @@ public class WireMockServerProxyTests
public async Task WireMockServer_Proxy_Should_log_proxied_requests()
{
// Assign
using var target = StartProxyTargetServer();
var settings = new WireMockServerSettings
{
ProxyAndRecordSettings = new ProxyAndRecordSettings
{
Url = "http://www.google.com",
Url = target.Url!,
SaveMapping = true,
SaveMappingToFile = false
}
@@ -327,12 +334,13 @@ public class WireMockServerProxyTests
public async Task WireMockServer_Proxy_Should_proxy_responses()
{
// Assign
using var target = StartProxyTargetServer();
var cancellationToken = TestContext.Current.CancellationToken;
string path = $"/prx_{Guid.NewGuid()}";
var server = WireMockServer.Start();
server
.Given(Request.Create().WithPath(path))
.RespondWith(Response.Create().WithProxy("http://www.google.com"));
.RespondWith(Response.Create().WithProxy(target.Url!));
// Act
var requestMessage = new HttpRequestMessage
@@ -348,7 +356,7 @@ public class WireMockServerProxyTests
// Assert
server.Mappings.Should().HaveCount(1);
server.LogEntries.Should().HaveCount(1);
content.Should().Contain("google");
content.Should().Be(ProxyTargetBody);
server.Stop();
}
@@ -946,6 +954,20 @@ public class WireMockServerProxyTests
server.LogEntries.Should().HaveCount(1);
server.Stop();
}
private const string ProxyTargetBody = "response from proxy target";
private static WireMockServer StartProxyTargetServer()
{
var target = WireMockServer.Start();
target
.Given(Request.Create().WithPath("/*"))
.RespondWith(Response.Create()
.WithStatusCode(HttpStatusCode.OK)
.WithBody(ProxyTargetBody));
return target;
}
}
@@ -45,6 +45,4 @@ public class WireMockServerProxy2Tests
server.Dispose();
serverAsProxy.Dispose();
}
}
}
@@ -1,13 +1,18 @@
// Copyright © WireMock.Net
using System.Net.Http;
using Moq;
using Scriban.Runtime;
using WireMock.Authentication;
using WireMock.Constants;
using WireMock.Logging;
using WireMock.Owin;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using WireMock.Settings;
using WireMock.Types;
namespace WireMock.Net.Tests;
@@ -228,5 +233,33 @@ public class WireMockServerSettingsTests
options.ActivityTracingOptions.Should().BeNull();
}
#endif
[Theory]
[InlineData(TransformerType.Scriban)]
[InlineData(TransformerType.ScribanDotLiquid)]
public async Task WireMockServer_WireMockServerSettings_ScribanRegistrationCallback_IsUsedInResponseTemplate(TransformerType transformerType)
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
var path = $"/scriban_{Guid.NewGuid()}";
using var server = WireMockServer.Start(new WireMockServerSettings
{
ScribanRegistrationCallback = (scriptObject, _) =>
((ScriptObject)scriptObject).Import("fake_name", new Func<string>(() => "Jane Doe"))
});
server
.Given(Request.Create().WithPath(path).UsingGet())
.RespondWith(Response.Create()
.WithBody("{{ fake_name }} {{ request.Path }}")
.WithTransformer(transformerType)
);
// Act
var response = await new HttpClient().GetStringAsync(server.Url + path, cancellationToken);
// Assert
response.Should().Be($"Jane Doe {path}");
}
}
@@ -0,0 +1,226 @@
// Copyright © WireMock.Net
using System.Net;
using WireMock.Models;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
namespace WireMock.Net.Tests;
public partial class WireMockServerTests
{
private static readonly Guid NearMissPostGuid = new("11111111-0000-0000-0000-000000000001");
private static readonly Guid NearMissGetGuid = new("11111111-0000-0000-0000-000000000002");
private static readonly Guid NearMissOtherGuid = new("11111111-0000-0000-0000-000000000003");
[Fact]
public async Task WireMockServer_FindUnmatchedLogEntries_ShouldOnlyReturnRequestsWithoutMatchingMapping()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
await client.GetAsync("/orders", cancellationToken);
await client.GetAsync("/order", cancellationToken);
// Act
var unmatched = server.FindUnmatchedLogEntries();
// Assert
unmatched.Should().ContainSingle();
unmatched[0].RequestMessage!.Path.Should().Be("/order");
unmatched[0].ResponseMessage!.StatusCode.Should().Be((int)HttpStatusCode.NotFound);
}
[Fact]
public async Task WireMockServer_FindNearMisses_ShouldReturnClosestMappingsFirst()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
// Path matches the POST and GET mappings, but the method is wrong for both.
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
var requestMessage = server.LogEntries.Single().RequestMessage!;
// Act
var nearMisses = server.FindNearMisses(requestMessage);
// Assert
nearMisses.Should().HaveCount(2);
nearMisses.Select(nm => nm.MappingGuid).Should().BeEquivalentTo([NearMissPostGuid, NearMissGetGuid]);
nearMisses.Should().AllSatisfy(nm =>
{
nm.LogEntryGuid.Should().BeNull();
nm.RequestMessage.Should().BeSameAs(requestMessage);
nm.RequestMatchResult.IsPerfectMatch.Should().BeFalse();
nm.RequestMatchResult.MatchDetails.Should().Contain(md => md.Name == "MethodMatcher" && md.Score < 1.0);
});
}
[Fact]
public async Task WireMockServer_FindNearMisses_ShouldRespectMaxResults()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
// Act
var nearMisses = server.FindNearMisses(server.LogEntries.Single().RequestMessage!, maxResults: 1);
// Assert
nearMisses.Should().ContainSingle();
}
[Fact]
public async Task WireMockServer_FindNearMisses_ShouldIgnoreDisabledMappings()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
server.Mappings.Single(m => m.Guid == NearMissPostGuid).IsDisabled = true;
using var client = server.CreateClient();
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
// Act
var nearMisses = server.FindNearMisses(server.LogEntries.Single().RequestMessage!);
// Assert
nearMisses.Select(nm => nm.MappingGuid).Should().NotContain(NearMissPostGuid);
}
[Fact]
public void WireMockServer_FindNearMisses_WithInvalidMaxResults_ShouldThrow()
{
// Arrange
using var server = WireMockServer.Start();
var requestMessage = new RequestMessage(new UrlDetails("http://localhost/orders"), "GET", "::1");
// Act
Action act = () => server.FindNearMisses(requestMessage, maxResults: 0);
// Assert
act.Should().Throw<ArgumentException>();
}
[Fact]
public async Task WireMockServer_FindNearMissesForUnmatchedRequests_ShouldReturnNearMissesPerUnmatchedRequest()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
await client.GetAsync("/orders", cancellationToken);
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
await client.DeleteAsync("/customers", cancellationToken);
var unmatchedGuids = server.FindUnmatchedLogEntries().Select(le => le.Guid).ToArray();
// Act
var nearMisses = server.FindNearMissesForUnmatchedRequests(maxResultsPerRequest: 1);
// Assert
unmatchedGuids.Should().HaveCount(2);
nearMisses.Should().HaveCount(2);
nearMisses.Select(nm => nm.LogEntryGuid!.Value).Should().BeEquivalentTo(unmatchedGuids);
nearMisses.Single(nm => nm.RequestMessage.Path == "/customers").MappingGuid.Should().Be(NearMissOtherGuid);
}
[Fact]
public async Task WireMockServer_FindNearMisses_ShouldNotReturnPerfectMatches()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
server
.Given(Request.Create().WithPath("/orders").UsingGet())
.AtPriority(100)
.WithTitle("Get orders (fallback)")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
using var client = server.CreateClient();
await client.GetAsync("/orders", cancellationToken);
// Act
var nearMisses = server.FindNearMisses(server.LogEntries.Single().RequestMessage!);
// Assert
nearMisses.Should().ContainSingle();
nearMisses[0].MappingGuid.Should().Be(NearMissPostGuid);
}
[Fact]
public async Task WireMockServer_FindNearMisses_ForLogEntry_ShouldSetLogEntryGuid()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
using var client = server.CreateClient();
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
var logEntry = server.LogEntries.Single();
// Act
var nearMisses = server.FindNearMisses(logEntry, maxResults: 1);
// Assert
nearMisses.Should().ContainSingle();
nearMisses[0].LogEntryGuid.Should().Be(logEntry.Guid);
nearMisses[0].RequestMessage.Should().BeSameAs(logEntry.RequestMessage);
}
[Fact]
public async Task WireMockServer_FindUnmatchedLogEntries_WithAllowPartialMapping_ShouldIncludeRequestsServedByAPartialMatch()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = StartServerWithNearMissMappings();
server.AllowPartialMapping();
using var client = server.CreateClient();
await client.GetAsync("/orders", cancellationToken);
await client.PutAsync("/orders", new StringContent("{}"), cancellationToken);
// Act
var unmatched = server.FindUnmatchedLogEntries();
var nearMisses = server.FindNearMissesForUnmatchedRequests();
// Assert
unmatched.Should().ContainSingle();
unmatched[0].RequestMessage!.Method.Should().Be("PUT");
unmatched[0].MappingGuid.Should().NotBeNull("the request was served by a partial match");
nearMisses.Select(nm => nm.MappingGuid).Should().Contain(unmatched[0].MappingGuid!.Value);
}
private static WireMockServer StartServerWithNearMissMappings()
{
var server = WireMockServer.Start();
server
.Given(Request.Create().WithPath("/orders").UsingPost())
.WithGuid(NearMissPostGuid)
.WithTitle("Create order")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.Created));
server
.Given(Request.Create().WithPath("/orders").UsingGet())
.WithGuid(NearMissGetGuid)
.WithTitle("Get orders")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK));
server
.Given(Request.Create().WithPath("/customers").UsingDelete().WithHeader("X-Api-Key", "secret"))
.WithGuid(NearMissOtherGuid)
.WithTitle("Delete customers")
.RespondWith(Response.Create().WithStatusCode(HttpStatusCode.NoContent));
return server;
}
}
@@ -640,6 +640,8 @@ public partial class WireMockServerTests(ITestOutputHelper testOutputHelper)
[Fact]
public async Task WireMockServer_Should_respond_to_ipv6_loopback()
{
Assert.SkipUnless(Socket.OSSupportsIPv6, "IPv6 is not supported on this machine.");
// Assign
var server = WireMockServer.Start();