Compare commits

...

17 Commits
2.0.0 ... 2.3.0

Author SHA1 Message Date
Stef Heyenrath
85d61a1877 2.3.0 2026-04-20 18:53:59 +02:00
Stef Heyenrath
885911203b Use DefaultJsonSerializer for BodyAsJson-Response (#1448) 2026-04-18 09:43:24 +02:00
Stef Heyenrath
1e591d5f8a Fix ExactMatcher and JsonMatcher not working for ISO dates as string (#1443) 2026-04-17 13:32:26 +02:00
Stef Heyenrath
02b7e3744e Update instructions.md (#1444) 2026-04-17 13:23:29 +02:00
dependabot[bot]
6e2a4d7e04 Bump log4net from 2.0.15 to 3.3.0 (#1440)
---
updated-dependencies:
- dependency-name: log4net
  dependency-version: 3.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 07:49:17 +02:00
Jayaraman Venkatesan
479bb0b8ec bug/wiremock-1268 moving Scenario state change before global response delay (#1436)
Co-authored-by: Stef Heyenrath <Stef.Heyenrath@gmail.com>
2026-04-03 10:51:24 +02:00
Stef Heyenrath
a453e00fdb Fix WireMock.Net.WebApplication.IIS example (#1435) 2026-03-31 22:52:27 +02:00
Stef Heyenrath
3214c2ebc7 2.2.0 2026-03-30 19:51:09 +02:00
Stef Heyenrath
6c6a42979e Add comments for ScenarioStateStore related code (#1433) 2026-03-30 19:49:28 +02:00
Stef Heyenrath
b4f5b9256c Upgrade Scriban.Signed (#1434) 2026-03-30 19:49:16 +02:00
Stef Heyenrath
070e4b6ab9 2.1.0 2026-03-29 13:16:23 +02:00
m4tchl0ck
f919929cb7 Add injectable IScenarioStateStore for distributed scenario state (#1430)
* Move ScenarioState to Abstractions and add IScenarioStateStore interface

ScenarioState is moved to the Abstractions project so it can be referenced
by the new IScenarioStateStore interface. The interface defines the contract
for storing and retrieving scenario states, enabling distributed implementations.

* Add InMemoryScenarioStateStore default implementation

Wraps ConcurrentDictionary with OrdinalIgnoreCase comparer, preserving
exact current behavior. The Update method encapsulates read-modify-write
so distributed implementations can make it atomic.

* Wire IScenarioStateStore into middleware options, settings, and consumers

Replace direct ConcurrentDictionary<string, ScenarioState> usage with
IScenarioStateStore across all consumer files. The store is injectable
via WireMockServerSettings.ScenarioStateStore, defaulting to the
InMemoryScenarioStateStore for backward compatibility.

* Add FileBasedScenarioStateStore for persistent scenario state

In-memory ConcurrentDictionary backed by JSON file persistence in
__admin/scenarios/. Reads from cache, mutations write through to disk.
Constructor loads existing state from disk on startup.

* Make ScenarioStateStore non-nullable with default InMemoryScenarioStateStore

Move InMemoryScenarioStateStore from WireMock.Net.Minimal to
WireMock.Net.Shared so it lives alongside WireMockServerSettings.
This allows WireMockServerSettings.ScenarioStateStore to be
non-nullable with a default value, following the same pattern as
DefaultJsonSerializer. The null-coalescing fallback in
WireMockMiddlewareOptionsHelper is no longer needed.
2026-03-25 13:04:44 +01:00
Logan Dam
cdd33695e5 Add helpers for query params fluent MappingModelBuilder (#1425)
* Add helpers for query params

* add example with query params

* add fluent helpers for WithHeaders and WithCookies

---------

Co-authored-by: Logan Dam <Logan.Dam@rabobank.com>
Co-authored-by: Stef Heyenrath <Stef.Heyenrath@gmail.com>
2026-03-14 10:39:30 +01:00
Stef Heyenrath
c4caa25eb6 Fix WithWebSocketProxy_Should_Proxy_Binary_Messages 2026-03-14 10:39:04 +01:00
Stef Heyenrath
ca788cb9b0 Add WireMockAspNetCoreLogger to log Kestrel warnings/errors (#1432)
* Add WireMockAspNetCoreLogger

* fix tests

* x

* .
2026-03-14 10:30:56 +01:00
Stef Heyenrath
d08ce944b6 Fix WireMockLogger implementation in dotnet-WireMock (#1431) 2026-03-13 18:34:47 +01:00
Stef Heyenrath
0a9f37e857 readme 2026-03-11 18:14:09 +01:00
63 changed files with 2421 additions and 1161 deletions

View File

@@ -1,4 +0,0 @@
# Copilot Instructions
## Project Guidelines
- All new byte[xx] calls should use using var data = ArrayPool<byte>.Shared.Lease(xx); instead of directly allocating byte arrays

15
.github/instructions/instructions.md vendored Normal file
View File

@@ -0,0 +1,15 @@
---
description: 'Guidelines for this solution'
applyTo: '**/*.csproj, **/*.cs'
---
# Multi .NET Framework Targeting
## Instructions
- The main project "WireMock.Net.Minimal" targets `netstandard2.0` and `net8.0`. Ensure that any new code or dependencies are compatible with these frameworks.
# C# Guidelines
## Instructions
- When a new ByteArray is needed, do not use `var data = new byte[bufferSize];`. Always use `var data = ArrayPool<byte>.Shared.Lease(bufferSize);`.

View File

@@ -1,3 +1,24 @@
# 2.3.0 (20 April 2026)
- [#1436](https://github.com/wiremock/WireMock.Net/pull/1436) - Moving Scenario state change before global response delay is set [feature] contributed by [jayaraman-venkatesan](https://github.com/jayaraman-venkatesan)
- [#1440](https://github.com/wiremock/WireMock.Net/pull/1440) - Bump log4net from 2.0.15 to 3.3.0 [dependencies] contributed by [dependabot[bot]](https://github.com/apps/dependabot)
- [#1443](https://github.com/wiremock/WireMock.Net/pull/1443) - Fix ExactMatcher and JsonMatcher not working for ISO dates as string [bug] contributed by [StefH](https://github.com/StefH)
- [#1444](https://github.com/wiremock/WireMock.Net/pull/1444) - Update instructions.md [refactor] contributed by [StefH](https://github.com/StefH)
- [#1448](https://github.com/wiremock/WireMock.Net/pull/1448) - Use DefaultJsonSerializer for BodyAsJson-Response [bug] contributed by [StefH](https://github.com/StefH)
- [#1205](https://github.com/wiremock/WireMock.Net/issues/1205) - System.Private.Uri 4.3.0 Blackduck security High vulnerability [bug]
- [#1260](https://github.com/wiremock/WireMock.Net/issues/1260) - Scenario state change before a response delay timeout ends [bug]
- [#1441](https://github.com/wiremock/WireMock.Net/issues/1441) - ExactMatcher and JsonMatcher not working for ISO dates in v2.2.0 [bug]
- [#1446](https://github.com/wiremock/WireMock.Net/issues/1446) - WithBodyAsJson does not return correctly formatted json when using SystemTextJsonConverter [bug]
# 2.2.0 (30 March 2026)
- [#1433](https://github.com/wiremock/WireMock.Net/pull/1433) - Add comments for ScenarioStateStore related code [refactor] contributed by [StefH](https://github.com/StefH)
- [#1434](https://github.com/wiremock/WireMock.Net/pull/1434) - Upgrade Scriban.Signed [security] contributed by [StefH](https://github.com/StefH)
# 2.1.0 (29 March 2026)
- [#1425](https://github.com/wiremock/WireMock.Net/pull/1425) - Add helpers for query params fluent MappingModelBuilder [feature] contributed by [biltongza](https://github.com/biltongza)
- [#1430](https://github.com/wiremock/WireMock.Net/pull/1430) - Add injectable IScenarioStateStore for distributed scenario state [feature] contributed by [m4tchl0ck](https://github.com/m4tchl0ck)
- [#1431](https://github.com/wiremock/WireMock.Net/pull/1431) - Fix WireMockLogger implementation in dotnet-WireMock [bug] contributed by [StefH](https://github.com/StefH)
- [#1432](https://github.com/wiremock/WireMock.Net/pull/1432) - Add WireMockAspNetCoreLogger to log Kestrel warnings/errors [feature] contributed by [StefH](https://github.com/StefH)
# 2.0.0 (11 March 2026)
- [#1359](https://github.com/wiremock/WireMock.Net/pull/1359) - Version 2.x contributed by [StefH](https://github.com/StefH)
- [#1394](https://github.com/wiremock/WireMock.Net/pull/1394) - MappingSerializer (Newtonsoft or SystemText)-Json [feature] contributed by [StefH](https://github.com/StefH)
@@ -1356,11 +1377,11 @@
- [#145](https://github.com/wiremock/WireMock.Net/pull/145) - Cancellation token not passed to server instance in .NET Core 2 [bug] contributed by [Bob11327](https://github.com/Bob11327)
# 1.0.3.18 (25 May 2018)
- [#142](https://github.com/wiremock/WireMock.Net/pull/142) - Allow all headers to be set as Response headers contributed by [StefH](https://github.com/StefH)
- [#142](https://github.com/wiremock/WireMock.Net/pull/142) - Allow all headers to be set as Response headers [bug] contributed by [StefH](https://github.com/StefH)
- [#122](https://github.com/wiremock/WireMock.Net/issues/122) - WireMock.Net not responding in unit tests - same works in console application
- [#126](https://github.com/wiremock/WireMock.Net/issues/126) - Question: UsingHead always returns 0 for Content-Length header even when explicitly specified
- [#132](https://github.com/wiremock/WireMock.Net/issues/132) - LogEntries not being recorded on subsequent tests
- [#136](https://github.com/wiremock/WireMock.Net/issues/136) - Question: Does the WireMock send Content-Length response header
- [#136](https://github.com/wiremock/WireMock.Net/issues/136) - Question: Does the WireMock send Content-Length response header [bug]
- [#137](https://github.com/wiremock/WireMock.Net/issues/137) - Question: How to specify Transfer-Encoding response header?
- [#139](https://github.com/wiremock/WireMock.Net/issues/139) - Wiki link https://github.com/StefH/WireMock.Net/wiki/Record-(via-proxy)-and-Save is dead

View File

@@ -4,7 +4,7 @@
</PropertyGroup>
<PropertyGroup>
<VersionPrefix>2.0.0</VersionPrefix>
<VersionPrefix>2.3.0</VersionPrefix>
<PackageIcon>WireMock.Net-Logo.png</PackageIcon>
<PackageProjectUrl>https://github.com/wiremock/WireMock.Net</PackageProjectUrl>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>

View File

@@ -1,7 +1,7 @@
rem https://github.com/StefH/GitHubReleaseNotes
SET version=2.0.0
SET version=2.3.0
GitHubReleaseNotes --output CHANGELOG.md --skip-empty-releases --exclude-labels wontfix test question invalid doc duplicate example environment --version %version% --token %GH_TOKEN%
GitHubReleaseNotes --output PackageReleaseNotes.txt --skip-empty-releases --exclude-labels 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%

View File

@@ -1,8 +1,12 @@
# 2.0.0 (11 March 2026)
- #1359 Version 2.x
- #1394 MappingSerializer (Newtonsoft or SystemText)-Json [feature]
- #1341 Configurable JSON serialization support (Newtonsoft.Json vs System.Text.Json) [feature]
- #1422 WireMock.Net seems to be incompatible with Microsoft.Owin.Security.Interop [bug]
- #1424 WireMock.Net.FluentAssertions is incompatible with WireMock.Net.Aspire [feature]
# 2.3.0 (20 April 2026)
- #1436 Moving Scenario state change before global response delay is set [feature]
- #1440 Bump log4net from 2.0.15 to 3.3.0 [dependencies]
- #1443 Fix ExactMatcher and JsonMatcher not working for ISO dates as string [bug]
- #1444 Update instructions.md [refactor]
- #1448 Use DefaultJsonSerializer for BodyAsJson-Response [bug]
- #1205 System.Private.Uri 4.3.0 Blackduck security High vulnerability [bug]
- #1260 Scenario state change before a response delay timeout ends [bug]
- #1441 ExactMatcher and JsonMatcher not working for ISO dates in v2.2.0 [bug]
- #1446 WithBodyAsJson does not return correctly formatted json when using SystemTextJsonConverter [bug]
The full release notes can be found here: https://github.com/wiremock/WireMock.Net/blob/master/CHANGELOG.md

View File

@@ -71,6 +71,7 @@ A C# .NET version based on [mock4net](https://github.com/alexvictoor/mock4net) w
| &nbsp;&nbsp;**WireMock.Net.OpenTelemetry** | [![NuGet Badge WireMock.Net.OpenTelemetry](https://img.shields.io/nuget/v/WireMock.Net.OpenTelemetry)](https://www.nuget.org/packages/WireMock.Net.ProtoBuf) | [![MyGet Badge WireMock.Net.OpenTelemetry](https://img.shields.io/myget/wiremock-net/vpre/WireMock.Net.OpenTelemetry?includePreReleases=true&label=MyGet)](https://www.myget.org/feed/wiremock-net/package/nuget/WireMock.Net.OpenTelemetry)
| | | |
| &nbsp;&nbsp;**WireMock.Net.RestClient** | [![NuGet Badge WireMock.Net.RestClient](https://img.shields.io/nuget/v/WireMock.Net.RestClient)](https://www.nuget.org/packages/WireMock.Net.RestClient) | [![MyGet Badge WireMock.Net.RestClient](https://img.shields.io/myget/wiremock-net/vpre/WireMock.Net.RestClient?includePreReleases=true&label=MyGet)](https://www.myget.org/feed/wiremock-net/package/nuget/WireMock.Net.RestClient)
| &nbsp;&nbsp;**WireMock.Net.RestClient.AwesomeAssertions** | [![NuGet Badge WireMock.Net.RestClient.AwesomeAssertions](https://img.shields.io/nuget/v/WireMock.Net.RestClient.AwesomeAssertions)](https://www.nuget.org/packages/WireMock.Net.RestClient.AwesomeAssertions) | [![MyGet Badge WireMock.Net.RestClient.AwesomeAssertions](https://img.shields.io/myget/wiremock-net/vpre/WireMock.Net.RestClient.AwesomeAssertions?includePreReleases=true&label=MyGet)](https://www.myget.org/feed/wiremock-net/package/nuget/WireMock.Net.RestClient.AwesomeAssertions)
| &nbsp;&nbsp;**WireMock.Org.RestClient** | [![NuGet Badge WireMock.Org.RestClient](https://img.shields.io/nuget/v/WireMock.Org.RestClient)](https://www.nuget.org/packages/WireMock.Org.RestClient) | [![MyGet Badge WireMock.Org.RestClient](https://img.shields.io/myget/wiremock-net/vpre/WireMock.Org.RestClient?includePreReleases=true&label=MyGet)](https://www.myget.org/feed/wiremock-net/package/nuget/WireMock.Org.RestClient)
<br />

View File

@@ -82,6 +82,51 @@ class Program
)
);
mappingBuilder.Given(m => m
.WithRequest(req => req
.WithPath("/testRequestWithQueryParams")
.UsingGet()
.WithParams(p => p.WithParam("param1", pb => pb.WithExactMatcher("value1")))
).WithResponse(rsp => rsp
.WithHeaders(h => h.Add("Content-Type", "application/json"))
.WithStatusCode(200)
.WithBodyAsJson(new
{
status = "ok"
}, true)
)
);
mappingBuilder.Given(m => m
.WithRequest(req => req
.WithPath("/testRequestWithHeaders")
.UsingGet()
.WithHeaders(h => h.WithHeader("Accept", hb => hb.WithExactMatcher("application/json")))
).WithResponse(rsp => rsp
.WithHeaders(h => h.Add("Content-Type", "application/json"))
.WithStatusCode(200)
.WithBodyAsJson(new
{
status = "ok"
}, true)
)
);
mappingBuilder.Given(m => m
.WithRequest(req => req
.WithPath("/testRequestWithCookie")
.UsingGet()
.WithCookies(c => c.WithCookie("cookie1", cb => cb.WithExactMatcher("cookievalue1")))
).WithResponse(rsp => rsp
.WithHeaders(h => h.Add("Content-Type", "application/json"))
.WithStatusCode(200)
.WithBodyAsJson(new
{
status = "ok"
}, true)
)
);
var result = await mappingBuilder.BuildAndPostAsync().ConfigureAwait(false);
Console.WriteLine($"result = {JsonConvert.SerializeObject(result)}");

View File

@@ -8,6 +8,7 @@ using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Newtonsoft.Json;
using SharpYaml.Model;
using WireMock.Logging;
using WireMock.Matchers;
using WireMock.Models;
@@ -288,7 +289,24 @@ namespace WireMock.Net.ConsoleApplication
var todos = new Dictionary<int, Todo>();
var server = WireMockServer.Start();
var server = WireMockServer.Start(new WireMockServerSettings
{
Logger = new WireMockConsoleLogger(),
Port = 9091
});
server
.WhenRequest(r => r
.WithPath("/Content-Length")
.UsingAnyMethod()
)
.ThenRespondWith(r => r
.WithStatusCode(HttpStatusCode.OK)
.WithHeader("Content-Length", "42")
);
System.Console.ReadLine();
//server
// .Given(Request.Create()

View File

@@ -18,7 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\WireMock.Net\WireMock.Net.csproj" />
<PackageReference Include="log4net" Version="2.0.15" />
<PackageReference Include="log4net" Version="3.3.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
</ItemGroup>

View File

@@ -1,29 +1,18 @@
// Copyright © WireMock.Net
using Microsoft.Extensions.Hosting;
using System.Threading;
using System.Threading.Tasks;
namespace WireMock.Net.WebApplication;
public class App : IHostedService
public class App(IWireMockService service) : IHostedService
{
private readonly IWireMockService _service;
public App(IWireMockService service)
{
_service = service;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_service.Start();
service.Start();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_service.Stop();
service.Stop();
return Task.CompletedTask;
}
}

View File

@@ -1,9 +1,5 @@
// Copyright © WireMock.Net
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using WireMock.Settings;
namespace WireMock.Net.WebApplication;

View File

@@ -15,7 +15,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\WireMock.Net.Abstractions\WireMock.Net.Abstractions.csproj" />
<ProjectReference Include="..\..\src\WireMock.Net\WireMock.Net.csproj" />
</ItemGroup>

View File

@@ -1,7 +1,5 @@
// Copyright © WireMock.Net
using System;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using WireMock.Admin.Requests;

View File

@@ -15,7 +15,7 @@
"WireMockServerSettings": {
"StartAdminInterface": true,
"Urls": [
"http://localhost"
"http://localhost:0"
]
}
}

View File

@@ -1,12 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<!--
<!--
Configure your application settings in appsettings.json. Learn more at http://go.microsoft.com/fwlink/?LinkId=786380
-->
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="%LAUNCHER_PATH%" arguments="%LAUNCHER_ARGS%" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" forwardWindowsAuthToken="false" />
</system.webServer>
-->
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<httpProtocol>
<customHeaders>
<add name="X-Frame-Options" value="SAMEORIGIN" />
</customHeaders>
</httpProtocol>
<aspNetCore processPath="%LAUNCHER_PATH%" arguments="%LAUNCHER_ARGS%" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" forwardWindowsAuthToken="false" />
</system.webServer>
</configuration>

View File

@@ -0,0 +1,39 @@
// Copyright © WireMock.Net
namespace WireMock.Admin.Mappings;
public partial class ArrayMatcherModelBuilder
{
public ArrayMatcherModelBuilder WithExactMatcher(object pattern, bool rejectOnMatch = false, bool ignoreCase = false)
{
return WithMatcher("ExactMatcher", pattern, rejectOnMatch, ignoreCase);
}
public ArrayMatcherModelBuilder WithWildcardMatcher(object pattern, bool rejectOnMatch = false, bool ignoreCase = false)
{
return WithMatcher("WildcardMatcher", pattern, rejectOnMatch, ignoreCase);
}
public ArrayMatcherModelBuilder WithRegexMatcher(object pattern, bool rejectOnMatch = false, bool ignoreCase = false)
{
return WithMatcher("RegexMatcher", pattern, rejectOnMatch, ignoreCase);
}
public ArrayMatcherModelBuilder WithNotNullOrEmptyMatcher(bool rejectOnMatch = false)
{
return Add(mb => mb
.WithName("NotNullOrEmptyMatcher")
.WithRejectOnMatch(rejectOnMatch)
);
}
private ArrayMatcherModelBuilder WithMatcher(string name, object pattern, bool rejectOnMatch, bool ignoreCase = false)
{
return Add(mb => mb
.WithName(name)
.WithPattern(pattern)
.WithRejectOnMatch(rejectOnMatch)
.WithIgnoreCase(ignoreCase)
);
}
}

View File

@@ -0,0 +1,19 @@
// Copyright © WireMock.Net
using System;
using System.Collections.Generic;
using System.Text;
namespace WireMock.Admin.Mappings;
public partial class IListCookieModelBuilder
{
public IListCookieModelBuilder WithCookie(string name, Action<IListMatcherModelBuilder> action, bool rejectOnMatch = false)
{
return Add(cookieBuilder => cookieBuilder
.WithName(name)
.WithRejectOnMatch(rejectOnMatch)
.WithMatchers(matchersBuilder => action(matchersBuilder))
);
}
}

View File

@@ -0,0 +1,19 @@
// Copyright © WireMock.Net
using System;
using System.Collections.Generic;
using System.Text;
namespace WireMock.Admin.Mappings;
public partial class IListHeaderModelBuilder
{
public IListHeaderModelBuilder WithHeader(string name, Action<IListMatcherModelBuilder> action, bool rejectOnMatch = false)
{
return Add(headerBuilder => headerBuilder
.WithName(name)
.WithRejectOnMatch(rejectOnMatch)
.WithMatchers(matchersBuilder => action(matchersBuilder))
);
}
}

View File

@@ -0,0 +1,39 @@
// Copyright © WireMock.Net
namespace WireMock.Admin.Mappings;
public partial class IListMatcherModelBuilder
{
public IListMatcherModelBuilder WithExactMatcher(object pattern, bool rejectOnMatch = false, bool ignoreCase = false)
{
return WithMatcher("ExactMatcher", pattern, rejectOnMatch, ignoreCase);
}
public IListMatcherModelBuilder WithWildcardMatcher(object pattern, bool rejectOnMatch = false, bool ignoreCase = false)
{
return WithMatcher("WildcardMatcher", pattern, rejectOnMatch, ignoreCase);
}
public IListMatcherModelBuilder WithRegexMatcher(object pattern, bool rejectOnMatch = false, bool ignoreCase = false)
{
return WithMatcher("RegexMatcher", pattern, rejectOnMatch, ignoreCase);
}
public IListMatcherModelBuilder WithNotNullOrEmptyMatcher(bool rejectOnMatch = false)
{
return Add(mb => mb
.WithName("NotNullOrEmptyMatcher")
.WithRejectOnMatch(rejectOnMatch)
);
}
private IListMatcherModelBuilder WithMatcher(string name, object pattern, bool rejectOnMatch, bool ignoreCase = false)
{
return Add(mb => mb
.WithName(name)
.WithPattern(pattern)
.WithRejectOnMatch(rejectOnMatch)
.WithIgnoreCase(ignoreCase)
);
}
}

View File

@@ -0,0 +1,17 @@
// Copyright © WireMock.Net
using System;
namespace WireMock.Admin.Mappings;
public partial class IListParamModelBuilder
{
public IListParamModelBuilder WithParam(string name, Action<ArrayMatcherModelBuilder> action, bool rejectOnMatch = false)
{
return Add(paramBuilder => paramBuilder
.WithName(name)
.WithRejectOnMatch(rejectOnMatch)
.WithMatchers(matchersBuilder => action(matchersBuilder))
);
}
}

View File

@@ -0,0 +1,24 @@
// Copyright © WireMock.Net
using System.Diagnostics.CodeAnalysis;
namespace WireMock.Handlers;
public interface IScenarioStateStore
{
bool TryGet(string name, [NotNullWhen(true)] out ScenarioState? state);
IReadOnlyList<ScenarioState> GetAll();
bool ContainsKey(string name);
bool TryAdd(string name, ScenarioState scenarioState);
ScenarioState AddOrUpdate(string name, Func<string, ScenarioState> addFactory, Func<string, ScenarioState, ScenarioState> updateFactory);
ScenarioState? Update(string name, Action<ScenarioState> updateAction);
bool TryRemove(string name);
void Clear();
}

View File

@@ -10,7 +10,7 @@ public class MatchDetail
/// <summary>
/// Gets or sets the type of the matcher.
/// </summary>
public required Type MatcherType { get; set; }
public required string MatcherType { get; set; }
/// <summary>
/// Gets or sets the type of the matcher.

View File

@@ -31,4 +31,4 @@ public class ScenarioState
/// Gets or sets the state counter.
/// </summary>
public int Counter { get; set; }
}
}

View File

@@ -33,12 +33,6 @@ public interface IWireMockServer : IDisposable
/// </summary>
IReadOnlyList<MappingModel> MappingModels { get; }
// <summary>
// Gets the mappings.
// </summary>
//[PublicAPI]
//IEnumerable<IMapping> Mappings { get; }
/// <summary>
/// Gets the ports.
/// </summary>
@@ -69,8 +63,6 @@ public interface IWireMockServer : IDisposable
/// </summary>
string? Provider { get; }
//ConcurrentDictionary<string, ScenarioState> Scenarios { get; }
/// <summary>
/// Occurs when [log entries changed].
/// </summary>
@@ -115,8 +107,6 @@ public interface IWireMockServer : IDisposable
/// <returns>The <see cref="IReadOnlyList{ILogEntry}"/>.</returns>
IReadOnlyList<ILogEntry> FindLogEntries(params IRequestMatcher[] matchers);
// IRespondWithAProvider Given(IRequestMatcher requestMatcher, bool saveToFile = false);
/// <summary>
/// Reads a static mapping file and adds or updates a single mapping.
///

View File

@@ -25,7 +25,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JsonConverter.Abstractions" Version="0.8.0" />
<PackageReference Include="JsonConverter.Abstractions" Version="0.9.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,145 @@
// Copyright © WireMock.Net
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json;
using Stef.Validation;
namespace WireMock.Handlers;
/// <summary>
/// Provides a file-based implementation of <see cref="IScenarioStateStore" /> that persists scenario states to disk and allows concurrent access.
/// </summary>
public class FileBasedScenarioStateStore : IScenarioStateStore
{
private readonly ConcurrentDictionary<string, ScenarioState> _scenarios = new(StringComparer.OrdinalIgnoreCase);
private readonly string _scenariosFolder;
private readonly object _lock = new();
/// <summary>
/// Initializes a new instance of the FileBasedScenarioStateStore class using the specified root folder as the base directory for scenario state storage.
/// </summary>
/// <param name="rootFolder">The root directory under which scenario state data will be stored. Must be a valid file system path.</param>
public FileBasedScenarioStateStore(string rootFolder)
{
Guard.NotNullOrEmpty(rootFolder);
_scenariosFolder = Path.Combine(rootFolder, "__admin", "scenarios");
Directory.CreateDirectory(_scenariosFolder);
LoadScenariosFromDisk();
}
/// <inheritdoc />
public bool TryGet(string name, [NotNullWhen(true)] out ScenarioState? state)
{
return _scenarios.TryGetValue(name, out state);
}
/// <inheritdoc />
public IReadOnlyList<ScenarioState> GetAll()
{
return _scenarios.Values.ToArray();
}
/// <inheritdoc />
public bool ContainsKey(string name)
{
return _scenarios.ContainsKey(name);
}
/// <inheritdoc />
public bool TryAdd(string name, ScenarioState scenarioState)
{
if (_scenarios.TryAdd(name, scenarioState))
{
WriteScenarioToFile(name, scenarioState);
return true;
}
return false;
}
/// <inheritdoc />
public ScenarioState AddOrUpdate(string name, Func<string, ScenarioState> addFactory, Func<string, ScenarioState, ScenarioState> updateFactory)
{
lock (_lock)
{
var result = _scenarios.AddOrUpdate(name, addFactory, updateFactory);
WriteScenarioToFile(name, result);
return result;
}
}
/// <inheritdoc />
public ScenarioState? Update(string name, Action<ScenarioState> updateAction)
{
lock (_lock)
{
if (_scenarios.TryGetValue(name, out var state))
{
updateAction(state);
WriteScenarioToFile(name, state);
return state;
}
return null;
}
}
/// <inheritdoc />
public bool TryRemove(string name)
{
if (_scenarios.TryRemove(name, out _))
{
DeleteScenarioFile(name);
return true;
}
return false;
}
/// <inheritdoc />
public void Clear()
{
_scenarios.Clear();
foreach (var file in Directory.GetFiles(_scenariosFolder, "*.json"))
{
File.Delete(file);
}
}
private string GetScenarioFilePath(string name)
{
var sanitized = string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
return Path.Combine(_scenariosFolder, sanitized + ".json");
}
private void WriteScenarioToFile(string name, ScenarioState state)
{
var json = JsonConvert.SerializeObject(state, Formatting.Indented);
File.WriteAllText(GetScenarioFilePath(name), json);
}
private void DeleteScenarioFile(string name)
{
var path = GetScenarioFilePath(name);
if (File.Exists(path))
{
File.Delete(path);
}
}
private void LoadScenariosFromDisk()
{
foreach (var file in Directory.GetFiles(_scenariosFolder, "*.json"))
{
var json = File.ReadAllText(file);
var state = JsonConvert.DeserializeObject<ScenarioState>(json);
if (state != null)
{
_scenarios.TryAdd(state.Name, state);
}
}
}
}

View File

@@ -0,0 +1,38 @@
// Copyright © WireMock.Net
using Microsoft.Extensions.Logging;
namespace WireMock.Logging;
internal sealed class WireMockAspNetCoreLogger(IWireMockLogger logger, string categoryName) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
{
return;
}
var message = formatter(state, exception);
if (exception != null)
{
message = $"{message} | Exception: {exception}";
}
switch (logLevel)
{
case LogLevel.Warning:
logger.Warn("[{0}] {1}", categoryName, message);
break;
default:
logger.Error("[{0}] {1}", categoryName, message);
break;
}
}
}

View File

@@ -0,0 +1,19 @@
// Copyright © WireMock.Net
using Microsoft.Extensions.Logging;
namespace WireMock.Logging;
internal sealed class WireMockAspNetCoreLoggerProvider : ILoggerProvider
{
private readonly IWireMockLogger _logger;
public WireMockAspNetCoreLoggerProvider(IWireMockLogger logger)
{
_logger = logger;
}
public ILogger CreateLogger(string categoryName) => new WireMockAspNetCoreLogger(_logger, categoryName);
public void Dispose() { }
}

View File

@@ -1,7 +1,5 @@
// Copyright © WireMock.Net
using System.Linq;
namespace WireMock.Matchers.Request;
/// <summary>
@@ -30,7 +28,7 @@ public class RequestMatchResult : IRequestMatchResult
return AddMatchDetail(new MatchDetail
{
Name = matcherType.Name.Replace("RequestMessage", string.Empty),
MatcherType = matcherType,
MatcherType = matcherType.Name,
Score = score,
Exception = exception
});

View File

@@ -1,9 +1,9 @@
// Copyright © WireMock.Net
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Stef.Validation;
using WireMock.Logging;
using WireMock.Owin.Mappers;
@@ -57,6 +57,12 @@ internal partial class AspNetCoreSelfHost
_host = builder
.UseSetting("suppressStatusMessages", "True") // https://andrewlock.net/suppressing-the-startup-and-shutdown-messages-in-asp-net-core/
.ConfigureAppConfigurationUsingEnvironmentVariables()
.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.AddProvider(new WireMockAspNetCoreLoggerProvider(_logger));
logging.SetMinimumLevel(LogLevel.Warning);
})
.ConfigureServices(services =>
{
services.AddSingleton(_wireMockMiddlewareOptions);
@@ -169,10 +175,10 @@ internal partial class AspNetCoreSelfHost
return _host.RunAsync(token);
}
catch (Exception e)
catch (Exception ex)
{
RunningException = e;
_logger.Error(e.ToString());
RunningException = ex;
_logger.Error("Error while RunAsync", ex);
IsStarted = false;

View File

@@ -2,6 +2,7 @@
using System.Collections.Concurrent;
using System.Security.Cryptography.X509Certificates;
using JsonConverter.Abstractions;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using WireMock.Handlers;
@@ -27,7 +28,7 @@ internal interface IWireMockMiddlewareOptions
ConcurrentDictionary<Guid, IMapping> Mappings { get; }
ConcurrentDictionary<string, ScenarioState> Scenarios { get; }
IScenarioStateStore ScenarioStateStore { get; set; }
ConcurrentObservableCollection<LogEntry> LogEntries { get; }
@@ -99,4 +100,12 @@ internal interface IWireMockMiddlewareOptions
/// WebSocket settings.
/// </summary>
WebSocketSettings? WebSocketSettings { get; set; }
/// <summary>
/// Gets or sets the default JSON converter used for serialization.
/// </summary>
/// <remarks>
/// Set this property to customize how objects are serialized to and deserialized from JSON during mapping.
/// </remarks>
IJsonConverter DefaultJsonSerializer { get; set; }
}

View File

@@ -1,257 +1,244 @@
// Copyright © WireMock.Net
using System.Globalization;
using System.Linq;
using System.Net;
using System.Reflection;
using System.Text;
using JsonConverter.Abstractions;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
using RandomDataGenerator.FieldOptions;
using RandomDataGenerator.Randomizers;
using Stef.Validation;
using WireMock.Http;
using WireMock.ResponseBuilders;
using WireMock.ResponseProviders;
using WireMock.Types;
using WireMock.Util;
namespace WireMock.Owin.Mappers
namespace WireMock.Owin.Mappers;
/// <summary>
/// OwinResponseMapper
/// </summary>
internal class OwinResponseMapper(IWireMockMiddlewareOptions options) : IOwinResponseMapper
{
/// <summary>
/// OwinResponseMapper
/// </summary>
internal class OwinResponseMapper : IOwinResponseMapper
private readonly IRandomizerNumber<double> _randomizerDouble = RandomizerFactory.GetRandomizer(new FieldOptionsDouble { Min = 0, Max = 1 });
private readonly IRandomizerBytes _randomizerBytes = RandomizerFactory.GetRandomizer(new FieldOptionsBytes { Min = 100, Max = 200 });
private readonly Encoding _utf8NoBom = new UTF8Encoding(false);
// https://msdn.microsoft.com/en-us/library/78h415ay(v=vs.110).aspx
private static readonly IDictionary<string, Action<HttpResponse, bool, WireMockList<string>>> ResponseHeadersToFix =
new Dictionary<string, Action<HttpResponse, bool, WireMockList<string>>>(StringComparer.OrdinalIgnoreCase)
{
{ HttpKnownHeaderNames.ContentType, (r, _, v) => r.ContentType = v.FirstOrDefault() },
{ HttpKnownHeaderNames.ContentLength, (r, hasBody, v) =>
{
// Only set the Content-Length header if the response does not have a body
if (!hasBody && long.TryParse(v.FirstOrDefault(), out var contentLength))
{
r.ContentLength = contentLength;
}
}
}
};
/// <inheritdoc />
public async Task MapAsync(IResponseMessage? responseMessage, HttpResponse response)
{
private readonly IRandomizerNumber<double> _randomizerDouble = RandomizerFactory.GetRandomizer(new FieldOptionsDouble { Min = 0, Max = 1 });
private readonly IRandomizerBytes _randomizerBytes = RandomizerFactory.GetRandomizer(new FieldOptionsBytes { Min = 100, Max = 200 });
private readonly IWireMockMiddlewareOptions _options;
private readonly Encoding _utf8NoBom = new UTF8Encoding(false);
// https://msdn.microsoft.com/en-us/library/78h415ay(v=vs.110).aspx
private static readonly IDictionary<string, Action<HttpResponse, bool, WireMockList<string>>> ResponseHeadersToFix =
new Dictionary<string, Action<HttpResponse, bool, WireMockList<string>>>(StringComparer.OrdinalIgnoreCase)
{
{ HttpKnownHeaderNames.ContentType, (r, _, v) => r.ContentType = v.FirstOrDefault() },
{ HttpKnownHeaderNames.ContentLength, (r, hasBody, v) =>
{
// Only set the Content-Length header if the response does not have a body
if (!hasBody && long.TryParse(v.FirstOrDefault(), out var contentLength))
{
r.ContentLength = contentLength;
}
}
}
};
/// <summary>
/// Constructor
/// </summary>
/// <param name="options">The IWireMockMiddlewareOptions.</param>
public OwinResponseMapper(IWireMockMiddlewareOptions options)
if (responseMessage == null || responseMessage is WebSocketHandledResponse)
{
_options = Guard.NotNull(options);
return;
}
/// <inheritdoc />
public async Task MapAsync(IResponseMessage? responseMessage, HttpResponse response)
var bodyData = responseMessage.BodyData;
if (bodyData?.GetDetectedBodyType() == BodyType.SseString)
{
if (responseMessage == null || responseMessage is WebSocketHandledResponse)
{
return;
}
var bodyData = responseMessage.BodyData;
if (bodyData?.GetDetectedBodyType() == BodyType.SseString)
{
await HandleSseStringAsync(responseMessage, response, bodyData);
return;
}
byte[]? bytes;
switch (responseMessage.FaultType)
{
case FaultType.EMPTY_RESPONSE:
bytes = IsFault(responseMessage) ? [] : await GetNormalBodyAsync(responseMessage).ConfigureAwait(false);
break;
case FaultType.MALFORMED_RESPONSE_CHUNK:
bytes = await GetNormalBodyAsync(responseMessage).ConfigureAwait(false) ?? [];
if (IsFault(responseMessage))
{
bytes = bytes.Take(bytes.Length / 2).Union(_randomizerBytes.Generate()).ToArray();
}
break;
default:
bytes = await GetNormalBodyAsync(responseMessage).ConfigureAwait(false);
break;
}
var statusCodeType = responseMessage.StatusCode?.GetType();
if (statusCodeType != null)
{
if (statusCodeType == typeof(int) || statusCodeType == typeof(int?) || statusCodeType.GetTypeInfo().IsEnum)
{
response.StatusCode = MapStatusCode((int)responseMessage.StatusCode!);
}
else if (statusCodeType == typeof(string))
{
// Note: this case will also match on null
int.TryParse(responseMessage.StatusCode as string, out var statusCodeTypeAsInt);
response.StatusCode = MapStatusCode(statusCodeTypeAsInt);
}
}
SetResponseHeaders(responseMessage, bytes != null, response);
if (bytes != null)
{
try
{
await response.Body.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false);
}
catch (Exception ex)
{
_options.Logger.Warn("Error writing response body. Exception : {0}", ex);
}
}
SetResponseTrailingHeaders(responseMessage, response);
await HandleSseStringAsync(responseMessage, response, bodyData);
return;
}
private static async Task HandleSseStringAsync(IResponseMessage responseMessage, HttpResponse response, IBodyData bodyData)
byte[]? bytes;
switch (responseMessage.FaultType)
{
if (bodyData.SseStringQueue == null)
{
return;
}
case FaultType.EMPTY_RESPONSE:
bytes = IsFault(responseMessage) ? [] : await GetNormalBodyAsync(responseMessage).ConfigureAwait(false);
break;
SetResponseHeaders(responseMessage, true, response);
string? text;
do
{
if (bodyData.SseStringQueue.TryRead(out text))
case FaultType.MALFORMED_RESPONSE_CHUNK:
bytes = await GetNormalBodyAsync(responseMessage).ConfigureAwait(false) ?? [];
if (IsFault(responseMessage))
{
await response.WriteAsync(text);
await response.Body.FlushAsync();
bytes = bytes.Take(bytes.Length / 2).Union(_randomizerBytes.Generate()).ToArray();
}
} while (text != null);
break;
default:
bytes = await GetNormalBodyAsync(responseMessage).ConfigureAwait(false);
break;
}
private int MapStatusCode(int code)
if (responseMessage.StatusCode is HttpStatusCode or int)
{
if (_options.AllowOnlyDefinedHttpStatusCodeInResponse == true && !Enum.IsDefined(typeof(HttpStatusCode), code))
response.StatusCode = MapStatusCode((int)responseMessage.StatusCode);
}
else if (responseMessage.StatusCode is string statusCodeAsString)
{
// Note: this case will also match on null
_ = int.TryParse(statusCodeAsString, out var statusCodeTypeAsInt);
response.StatusCode = MapStatusCode(statusCodeTypeAsInt);
}
SetResponseHeaders(responseMessage, bytes != null, response);
if (bytes != null)
{
try
{
return (int)HttpStatusCode.OK;
await response.Body.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false);
}
return code;
}
private bool IsFault(IResponseMessage responseMessage)
{
return responseMessage.FaultPercentage == null || _randomizerDouble.Generate() <= responseMessage.FaultPercentage;
}
private async Task<byte[]?> GetNormalBodyAsync(IResponseMessage responseMessage)
{
var bodyData = responseMessage.BodyData;
switch (bodyData?.GetDetectedBodyType())
catch (Exception ex)
{
case BodyType.String:
case BodyType.FormUrlEncoded:
return (bodyData.Encoding ?? _utf8NoBom).GetBytes(bodyData.BodyAsString!);
case BodyType.Json:
var formatting = bodyData.BodyAsJsonIndented == true ? Formatting.Indented : Formatting.None;
var jsonBody = JsonConvert.SerializeObject(bodyData.BodyAsJson, new JsonSerializerSettings { Formatting = formatting, NullValueHandling = NullValueHandling.Ignore });
return (bodyData.Encoding ?? _utf8NoBom).GetBytes(jsonBody);
case BodyType.ProtoBuf:
if (TypeLoader.TryLoadStaticInstance<IProtoBufUtils>(out var protoBufUtils))
{
var protoDefinitions = bodyData.ProtoDefinition?.Invoke().Texts;
return await protoBufUtils.GetProtoBufMessageWithHeaderAsync(protoDefinitions, bodyData.ProtoBufMessageType, bodyData.BodyAsJson).ConfigureAwait(false);
}
break;
case BodyType.Bytes:
return bodyData.BodyAsBytes;
case BodyType.File:
return _options.FileSystemHandler?.ReadResponseBodyAsFile(bodyData.BodyAsFile!);
case BodyType.MultiPart:
_options.Logger.Warn("MultiPart body type is not handled!");
break;
case BodyType.None:
break;
}
return null;
}
private static void SetResponseHeaders(IResponseMessage responseMessage, bool hasBody, HttpResponse response)
{
// Force setting the Date header (#577)
AppendResponseHeader(
response,
HttpKnownHeaderNames.Date,
[DateTime.UtcNow.ToString(CultureInfo.InvariantCulture.DateTimeFormat.RFC1123Pattern, CultureInfo.InvariantCulture)]
);
// Set other headers
foreach (var item in responseMessage.Headers!)
{
var headerName = item.Key;
var value = item.Value;
if (ResponseHeadersToFix.TryGetValue(headerName, out var action))
{
action.Invoke(response, hasBody, value);
}
else
{
// Check if this response header can be added (#148, #227 and #720)
if (!HttpKnownHeaderNames.IsRestrictedResponseHeader(headerName))
{
AppendResponseHeader(response, headerName, value.ToArray());
}
}
options.Logger.Warn("Error writing response body. Exception : {0}", ex);
}
}
private static void SetResponseTrailingHeaders(IResponseMessage responseMessage, HttpResponse response)
{
if (responseMessage.TrailingHeaders == null)
{
return;
}
SetResponseTrailingHeaders(responseMessage, response);
}
#if TRAILINGHEADERS
foreach (var (headerName, value) in responseMessage.TrailingHeaders)
{
if (ResponseHeadersToFix.TryGetValue(headerName, out var action))
{
action.Invoke(response, false, value);
}
else
{
// Check if this trailing header can be added to the response
if (response.SupportsTrailers() && !HttpKnownHeaderNames.IsRestrictedResponseHeader(headerName))
{
response.AppendTrailer(headerName, new Microsoft.Extensions.Primitives.StringValues(value.ToArray()));
}
}
}
#endif
private static async Task HandleSseStringAsync(IResponseMessage responseMessage, HttpResponse response, IBodyData bodyData)
{
if (bodyData.SseStringQueue == null)
{
return;
}
private static void AppendResponseHeader(HttpResponse response, string headerName, string[] values)
SetResponseHeaders(responseMessage, true, response);
string? text;
do
{
response.Headers.Append(headerName, values);
if (bodyData.SseStringQueue.TryRead(out text))
{
await response.WriteAsync(text);
await response.Body.FlushAsync();
}
} while (text != null);
}
private int MapStatusCode(int code)
{
if (options.AllowOnlyDefinedHttpStatusCodeInResponse == true && !Enum.IsDefined(typeof(HttpStatusCode), code))
{
return (int)HttpStatusCode.OK;
}
return code;
}
private bool IsFault(IResponseMessage responseMessage)
{
return responseMessage.FaultPercentage == null || _randomizerDouble.Generate() <= responseMessage.FaultPercentage;
}
private async Task<byte[]?> GetNormalBodyAsync(IResponseMessage responseMessage)
{
var bodyData = responseMessage.BodyData;
switch (bodyData?.GetDetectedBodyType())
{
case BodyType.String:
case BodyType.FormUrlEncoded:
return (bodyData.Encoding ?? _utf8NoBom).GetBytes(bodyData.BodyAsString!);
case BodyType.Json:
var jsonConverterOptions = new JsonConverterOptions
{
WriteIndented = bodyData.BodyAsJsonIndented == true,
IgnoreNullValues = true
};
var jsonBody = options.DefaultJsonSerializer.Serialize(bodyData.BodyAsJson!, jsonConverterOptions);
return (bodyData.Encoding ?? _utf8NoBom).GetBytes(jsonBody);
case BodyType.ProtoBuf:
if (TypeLoader.TryLoadStaticInstance<IProtoBufUtils>(out var protoBufUtils))
{
var protoDefinitions = bodyData.ProtoDefinition?.Invoke().Texts;
return await protoBufUtils.GetProtoBufMessageWithHeaderAsync(protoDefinitions, bodyData.ProtoBufMessageType, bodyData.BodyAsJson).ConfigureAwait(false);
}
break;
case BodyType.Bytes:
return bodyData.BodyAsBytes;
case BodyType.File:
return options.FileSystemHandler?.ReadResponseBodyAsFile(bodyData.BodyAsFile!);
case BodyType.MultiPart:
options.Logger.Warn("MultiPart body type is not handled!");
break;
case BodyType.None:
break;
}
return null;
}
private static void SetResponseHeaders(IResponseMessage responseMessage, bool hasBody, HttpResponse response)
{
// Force setting the Date header (#577)
AppendResponseHeader(
response,
HttpKnownHeaderNames.Date,
[DateTime.UtcNow.ToString(CultureInfo.InvariantCulture.DateTimeFormat.RFC1123Pattern, CultureInfo.InvariantCulture)]
);
// Set other headers
foreach (var item in responseMessage.Headers!)
{
var headerName = item.Key;
var value = item.Value;
if (ResponseHeadersToFix.TryGetValue(headerName, out var action))
{
action.Invoke(response, hasBody, value);
}
else
{
// Check if this response header can be added (#148, #227 and #720)
if (!HttpKnownHeaderNames.IsRestrictedResponseHeader(headerName))
{
AppendResponseHeader(response, headerName, value.ToArray());
}
}
}
}
private static void SetResponseTrailingHeaders(IResponseMessage responseMessage, HttpResponse response)
{
if (responseMessage.TrailingHeaders == null)
{
return;
}
#if TRAILINGHEADERS
foreach (var (headerName, value) in responseMessage.TrailingHeaders)
{
if (ResponseHeadersToFix.TryGetValue(headerName, out var action))
{
action.Invoke(response, false, value);
}
else
{
// Check if this trailing header can be added to the response
if (response.SupportsTrailers() && !HttpKnownHeaderNames.IsRestrictedResponseHeader(headerName))
{
response.AppendTrailer(headerName, new Microsoft.Extensions.Primitives.StringValues(value.ToArray()));
}
}
}
#endif
}
private static void AppendResponseHeader(HttpResponse response, string headerName, string[] values)
{
response.Headers.Append(headerName, values);
}
}

View File

@@ -89,14 +89,13 @@ internal class MappingMatcher(IWireMockMiddlewareOptions options, IRandomizerDou
private string? GetNextState(IMapping mapping)
{
// If the mapping does not have a scenario or _options.Scenarios does not contain this scenario from the mapping,
// If the mapping does not have a scenario or the store does not contain this scenario,
// just return null to indicate that there is no next state.
if (mapping.Scenario == null || !_options.Scenarios.ContainsKey(mapping.Scenario))
if (mapping.Scenario == null)
{
return null;
}
// Else just return the next state
return _options.Scenarios[mapping.Scenario].NextState;
return _options.ScenarioStateStore.TryGet(mapping.Scenario, out var state) ? state.NextState : null;
}
}

View File

@@ -81,9 +81,9 @@ internal class WireMockMiddleware(
}
// Set scenario start
if (!options.Scenarios.ContainsKey(mapping.Scenario) && mapping.IsStartState)
if (!options.ScenarioStateStore.ContainsKey(mapping.Scenario) && mapping.IsStartState)
{
options.Scenarios.TryAdd(mapping.Scenario, new ScenarioState
options.ScenarioStateStore.TryAdd(mapping.Scenario, new ScenarioState
{
Name = mapping.Scenario
});
@@ -122,6 +122,14 @@ internal class WireMockMiddleware(
}
}
// Transition scenario state immediately after matching, before any delay (global or
// per-mapping) so that concurrent retries arriving during a delay period see the
// updated state and match the correct next mapping instead of re-matching this one.
if (targetMapping.Scenario != null)
{
UpdateScenarioState(targetMapping);
}
if (!targetMapping.IsAdminInterface && options.RequestProcessingDelay > TimeSpan.Zero)
{
await Task.Delay(options.RequestProcessingDelay.Value).ConfigureAwait(false);
@@ -147,11 +155,6 @@ internal class WireMockMiddleware(
}
}
if (targetMapping.Scenario != null)
{
UpdateScenarioState(targetMapping);
}
if (!targetMapping.IsAdminInterface && targetMapping.Webhooks?.Length > 0)
{
await SendToWebhooksAsync(targetMapping, request, response).ConfigureAwait(false);
@@ -233,20 +236,21 @@ internal class WireMockMiddleware(
private void UpdateScenarioState(IMapping mapping)
{
var scenario = options.Scenarios[mapping.Scenario!];
// Increase the number of times this state has been executed
scenario.Counter++;
// Only if the number of times this state is executed equals the required StateTimes, proceed to next state and reset the counter to 0
if (scenario.Counter == (mapping.TimesInSameState ?? 1))
options.ScenarioStateStore.Update(mapping.Scenario!, scenario =>
{
scenario.NextState = mapping.NextState;
scenario.Counter = 0;
}
// Increase the number of times this state has been executed
scenario.Counter++;
// Else just update Started and Finished
scenario.Started = true;
scenario.Finished = mapping.NextState == null;
// Only if the number of times this state is executed equals the required StateTimes, proceed to next state and reset the counter to 0
if (scenario.Counter == (mapping.TimesInSameState ?? 1))
{
scenario.NextState = mapping.NextState;
scenario.Counter = 0;
}
// Else just update Started and Finished
scenario.Started = true;
scenario.Finished = mapping.NextState == null;
});
}
}
}

View File

@@ -2,6 +2,8 @@
using System.Collections.Concurrent;
using System.Security.Cryptography.X509Certificates;
using JsonConverter.Abstractions;
using JsonConverter.Newtonsoft.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using WireMock.Handlers;
@@ -27,7 +29,7 @@ internal class WireMockMiddlewareOptions : IWireMockMiddlewareOptions
public ConcurrentDictionary<Guid, IMapping> Mappings { get; } = new ConcurrentDictionary<Guid, IMapping>();
public ConcurrentDictionary<string, ScenarioState> Scenarios { get; } = new(StringComparer.OrdinalIgnoreCase);
public IScenarioStateStore ScenarioStateStore { get; set; } = new InMemoryScenarioStateStore();
public ConcurrentObservableCollection<LogEntry> LogEntries { get; } = new();
@@ -108,5 +110,9 @@ internal class WireMockMiddlewareOptions : IWireMockMiddlewareOptions
/// <inheritdoc />
public ConcurrentDictionary<Guid, WebSocketConnectionRegistry> WebSocketRegistries { get; } = new();
/// <inheritdoc />
public WebSocketSettings? WebSocketSettings { get; set; }
/// <inheritdoc />
public IJsonConverter DefaultJsonSerializer { get; set; } = new NewtonsoftJsonConverter();
}

View File

@@ -27,6 +27,7 @@ internal static class WireMockMiddlewareOptionsHelper
options.FileSystemHandler = settings.FileSystemHandler;
options.HandleRequestsSynchronously = settings.HandleRequestsSynchronously;
options.Logger = settings.Logger;
options.ScenarioStateStore = settings.ScenarioStateStore;
options.MaxRequestLogCount = settings.MaxRequestLogCount;
options.PostWireMockMiddlewareInit = settings.PostWireMockMiddlewareInit;
options.PreWireMockMiddlewareInit = settings.PreWireMockMiddlewareInit;

View File

@@ -1,6 +1,7 @@
// Copyright © WireMock.Net
using JsonConverter.Abstractions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
#if NETSTANDARD2_0_OR_GREATER || NETCOREAPP3_1_OR_GREATER || NET6_0_OR_GREATER || NET461
using System.Text.Json;
@@ -10,9 +11,15 @@ namespace WireMock.Serialization;
internal class MappingSerializer(IJsonConverter jsonConverter)
{
private static readonly JsonConverterOptions JsonConverterOptions = new JsonConverterOptions
{
DateParseHandling = (int) DateParseHandling.None
};
internal T[] DeserializeJsonToArray<T>(string value)
{
return DeserializeObjectToArray<T>(jsonConverter.Deserialize<object>(value)!);
// DeserializeObject
return DeserializeObjectToArray<T>(jsonConverter.Deserialize<object>(value, JsonConverterOptions)!);
}
internal static T[] DeserializeObjectToArray<T>(object value)

View File

@@ -1,6 +1,5 @@
// Copyright © WireMock.Net
using System.Linq;
using System.Net;
using System.Text;
using JetBrains.Annotations;
@@ -26,9 +25,6 @@ using WireMock.Util;
namespace WireMock.Server;
/// <summary>
/// The fluent mock server.
/// </summary>
public partial class WireMockServer
{
private const int EnhancedFileSystemWatcherTimeoutMs = 1000;
@@ -672,7 +668,7 @@ public partial class WireMockServer
#region Scenarios
private IResponseMessage ScenariosGet(HttpContext _, IRequestMessage requestMessage)
{
var scenariosStates = Scenarios.Values.Select(s => new ScenarioStateModel
var scenariosStates = Scenarios.Select(s => new ScenarioStateModel
{
Name = s.Name,
NextState = s.NextState,
@@ -705,7 +701,7 @@ public partial class WireMockServer
private IResponseMessage ScenariosSetState(HttpContext _, IRequestMessage requestMessage)
{
var name = Enumerable.Reverse(requestMessage.Path.Split('/')).Skip(1).First();
if (!_options.Scenarios.ContainsKey(name))
if (!_options.ScenarioStateStore.ContainsKey(name))
{
ResponseMessageBuilder.Create(HttpStatusCode.NotFound, $"No scenario found by name '{name}'.");
}

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@ internal class WireMockListAccessor : IListAccessor, IObjectAccessor
return target?.ToString() ?? string.Empty;
}
public void SetValue(TemplateContext context, SourceSpan span, object target, int index, object value)
public void SetValue(TemplateContext context, SourceSpan span, object target, int index, object? value)
{
throw new NotImplementedException();
}
@@ -46,7 +46,7 @@ internal class WireMockListAccessor : IListAccessor, IObjectAccessor
throw new NotImplementedException();
}
public bool TrySetValue(TemplateContext context, SourceSpan span, object target, string member, object value)
public bool TrySetValue(TemplateContext context, SourceSpan span, object target, string member, object? value)
{
throw new NotImplementedException();
}
@@ -56,7 +56,7 @@ internal class WireMockListAccessor : IListAccessor, IObjectAccessor
throw new NotImplementedException();
}
public bool TrySetItem(TemplateContext context, SourceSpan span, object target, object index, object value)
public bool TrySetItem(TemplateContext context, SourceSpan span, object target, object index, object? value)
{
throw new NotImplementedException();
}

View File

@@ -8,9 +8,9 @@ namespace WireMock.Transformers.Scriban;
internal class WireMockTemplateContext : TemplateContext
{
protected override IObjectAccessor GetMemberAccessorImpl(object target)
protected override IObjectAccessor? GetMemberAccessorImpl(object target)
{
return target?.GetType().GetGenericTypeDefinition() == typeof(WireMockList<>) ?
return target.GetType().GetGenericTypeDefinition() == typeof(WireMockList<>) ?
new WireMockListAccessor() :
base.GetMemberAccessorImpl(target);
}

View File

@@ -43,7 +43,7 @@
<PackageReference Include="SimMetrics.Net" Version="1.0.5" />
<PackageReference Include="TinyMapper.Signed" Version="4.0.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="6.34.0" />
<PackageReference Include="Scriban.Signed" Version="5.5.0" />
<PackageReference Include="Scriban.Signed" Version="7.0.6" />
</ItemGroup>
<ItemGroup>

View File

@@ -25,7 +25,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JsonConverter.Newtonsoft.Json" Version="0.8.0" />
<PackageReference Include="JsonConverter.Newtonsoft.Json" Version="0.9.0" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="Stef.Validation" Version="0.2.0" />
</ItemGroup>

View File

@@ -33,7 +33,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JsonConverter.Newtonsoft.Json" Version="0.8.0" />
<PackageReference Include="JsonConverter.Newtonsoft.Json" Version="0.9.0" />
<PackageReference Include="RestEase" Version="1.6.4" />
<PackageReference Include="Stef.Validation" Version="0.2.0" />
</ItemGroup>

View File

@@ -0,0 +1,68 @@
// Copyright © WireMock.Net
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
namespace WireMock.Handlers;
/// <summary>
/// Provides an in-memory implementation of the <see cref="IScenarioStateStore" /> interface for managing scenario state objects by name.
/// </summary>
public class InMemoryScenarioStateStore : IScenarioStateStore
{
private readonly ConcurrentDictionary<string, ScenarioState> _scenarios = new(StringComparer.OrdinalIgnoreCase);
/// <inheritdoc />
public bool TryGet(string name, [NotNullWhen(true)] out ScenarioState? state)
{
return _scenarios.TryGetValue(name, out state);
}
/// <inheritdoc />
public IReadOnlyList<ScenarioState> GetAll()
{
return _scenarios.Values.ToArray();
}
/// <inheritdoc />
public bool ContainsKey(string name)
{
return _scenarios.ContainsKey(name);
}
/// <inheritdoc />
public bool TryAdd(string name, ScenarioState scenarioState)
{
return _scenarios.TryAdd(name, scenarioState);
}
/// <inheritdoc />
public ScenarioState AddOrUpdate(string name, Func<string, ScenarioState> addFactory, Func<string, ScenarioState, ScenarioState> updateFactory)
{
return _scenarios.AddOrUpdate(name, addFactory, updateFactory);
}
/// <inheritdoc />
public ScenarioState? Update(string name, Action<ScenarioState> updateAction)
{
if (_scenarios.TryGetValue(name, out var state))
{
updateAction(state);
return state;
}
return null;
}
/// <inheritdoc />
public bool TryRemove(string name)
{
return _scenarios.TryRemove(name, out _);
}
/// <inheritdoc />
public void Clear()
{
_scenarios.Clear();
}
}

View File

@@ -1,6 +1,5 @@
// Copyright © WireMock.Net
using System.Linq;
using Stef.Validation;
using WireMock.Extensions;
using WireMock.Matchers.Request;
@@ -119,7 +118,7 @@ public class MatchResult
return new MatchDetail
{
Name = Name,
MatcherType = typeof(MatchResult),
MatcherType = typeof(MatchResult).Name,
Score = Score,
Exception = Exception,
MatchDetails = MatchResults?.Select(mr => mr.ToMatchDetail()).ToArray()

View File

@@ -175,6 +175,17 @@ public class WireMockServerSettings
[JsonIgnore]
public IFileSystemHandler FileSystemHandler { get; set; } = null!;
/// <summary>
/// Gets or sets the store used to persist scenario state information.
/// </summary>
/// <remarks>
/// The scenario state store manages the storage and retrieval of state data associated with scenarios.
/// By default, an in-memory implementation is used, but this property can be set to a custom implementation to support alternative storage mechanisms such as databases or distributed caches.
/// </remarks>
[PublicAPI]
[JsonIgnore]
public IScenarioStateStore ScenarioStateStore { get; set; } = new InMemoryScenarioStateStore();
/// <summary>
/// Action which can be used to add additional Handlebars registrations. [Optional]
/// </summary>
@@ -254,7 +265,7 @@ public class WireMockServerSettings
/// Whether to accept any client certificate
/// </summary>
public bool AcceptAnyClientCertificate { get; set; }
/// <summary>
/// Defines the global IWebhookSettings to use.
/// </summary>

View File

@@ -30,7 +30,7 @@
<PackageReference Include="Stef.Validation" Version="0.2.0" />
<PackageReference Include="AnyOf" Version="0.5.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="JsonConverter.Newtonsoft.Json" Version="0.8.0" />
<PackageReference Include="JsonConverter.Newtonsoft.Json" Version="0.9.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,6 +1,7 @@
// Copyright © WireMock.Net
using System.Text.Json.Serialization;
using Newtonsoft.Json.Linq;
using WireMock.Admin.Mappings;
using WireMock.Admin.Requests;
using WireMock.Types;
@@ -9,10 +10,13 @@ namespace WireMock.Net.Json;
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(EncodingModel))]
[JsonSerializable(typeof(JArray))]
[JsonSerializable(typeof(JObject))]
[JsonSerializable(typeof(LogEntryModel))]
[JsonSerializable(typeof(LogRequestModel))]
[JsonSerializable(typeof(LogResponseModel))]
[JsonSerializable(typeof(LogRequestMatchModel))]
[JsonSerializable(typeof(StatusModel))]
[JsonSerializable(typeof(WireMockList<string>))]
internal partial class SourceGenerationContext : JsonSerializerContext
{

View File

@@ -1,6 +1,5 @@
// Copyright © WireMock.Net
using System;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using WireMock.Admin.Requests;
@@ -11,11 +10,6 @@ namespace WireMock.Net;
public class WireMockLogger : IWireMockLogger
{
private readonly JsonSerializerOptions _options = new()
{
WriteIndented = true
};
private readonly ILogger _logger;
public WireMockLogger(ILogger logger)

View File

@@ -11,12 +11,12 @@
<ItemGroup>
<PackageReference Include="AwesomeAssertions" Version="9.4.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PackageReference Include="coverlet.collector" Version="8.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="WireMock.Net" Version="1.25.0" />
<PackageReference Include="WireMock.Net" Version="2.1.0" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>

View File

@@ -0,0 +1,273 @@
// Copyright © WireMock.Net
using System;
using System.IO;
using Newtonsoft.Json;
using WireMock.Handlers;
using Xunit;
namespace WireMock.Net.Tests.Handlers;
public class FileBasedScenarioStateStoreTests : IDisposable
{
private readonly string _tempFolder;
private readonly string _scenariosFolder;
public FileBasedScenarioStateStoreTests()
{
_tempFolder = Path.Combine(Path.GetTempPath(), "WireMock_Tests_" + Guid.NewGuid().ToString("N"));
_scenariosFolder = Path.Combine(_tempFolder, "__admin", "scenarios");
}
public void Dispose()
{
if (Directory.Exists(_tempFolder))
{
Directory.Delete(_tempFolder, true);
}
}
private FileBasedScenarioStateStore CreateSut() => new(_tempFolder);
// --- Mirror tests from InMemoryScenarioStateStoreTests ---
[Fact]
public void TryAdd_ShouldAddNewScenario()
{
var sut = CreateSut();
var state = new ScenarioState { Name = "scenario1" };
sut.TryAdd("scenario1", state).Should().BeTrue();
sut.ContainsKey("scenario1").Should().BeTrue();
}
[Fact]
public void TryAdd_ShouldReturnFalse_WhenScenarioAlreadyExists()
{
var sut = CreateSut();
var state = new ScenarioState { Name = "scenario1" };
sut.TryAdd("scenario1", state);
sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" }).Should().BeFalse();
}
[Fact]
public void TryGet_ShouldReturnTrue_WhenExists()
{
var sut = CreateSut();
var state = new ScenarioState { Name = "scenario1", NextState = "state2" };
sut.TryAdd("scenario1", state);
sut.TryGet("scenario1", out var result).Should().BeTrue();
result.Should().NotBeNull();
result!.NextState.Should().Be("state2");
}
[Fact]
public void TryGet_ShouldReturnFalse_WhenNotExists()
{
var sut = CreateSut();
sut.TryGet("nonexistent", out var result).Should().BeFalse();
result.Should().BeNull();
}
[Fact]
public void GetAll_ShouldReturnAllScenarios()
{
var sut = CreateSut();
sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" });
sut.TryAdd("scenario2", new ScenarioState { Name = "scenario2" });
var result = sut.GetAll();
result.Should().HaveCount(2);
}
[Fact]
public void GetAll_ShouldReturnEmpty_WhenNoScenarios()
{
var sut = CreateSut();
sut.GetAll().Should().BeEmpty();
}
[Fact]
public void Update_ShouldModifyExistingScenario()
{
var sut = CreateSut();
sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1", Counter = 0 });
var result = sut.Update("scenario1", s => { s.Counter = 5; s.NextState = "state2"; });
result.Should().NotBeNull();
result!.Counter.Should().Be(5);
result.NextState.Should().Be("state2");
}
[Fact]
public void Update_ShouldReturnNull_WhenNotExists()
{
var sut = CreateSut();
sut.Update("nonexistent", s => { s.Counter = 5; }).Should().BeNull();
}
[Fact]
public void AddOrUpdate_ShouldAddNewScenario()
{
var sut = CreateSut();
var result = sut.AddOrUpdate(
"scenario1",
_ => new ScenarioState { Name = "scenario1", NextState = "added" },
(_, current) => { current.NextState = "updated"; return current; }
);
result.NextState.Should().Be("added");
}
[Fact]
public void AddOrUpdate_ShouldUpdateExistingScenario()
{
var sut = CreateSut();
sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1", NextState = "initial" });
var result = sut.AddOrUpdate(
"scenario1",
_ => new ScenarioState { Name = "scenario1", NextState = "added" },
(_, current) => { current.NextState = "updated"; return current; }
);
result.NextState.Should().Be("updated");
}
[Fact]
public void TryRemove_ShouldRemoveExistingScenario()
{
var sut = CreateSut();
sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" });
sut.TryRemove("scenario1").Should().BeTrue();
sut.ContainsKey("scenario1").Should().BeFalse();
}
[Fact]
public void TryRemove_ShouldReturnFalse_WhenNotExists()
{
var sut = CreateSut();
sut.TryRemove("nonexistent").Should().BeFalse();
}
[Fact]
public void Clear_ShouldRemoveAllScenarios()
{
var sut = CreateSut();
sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" });
sut.TryAdd("scenario2", new ScenarioState { Name = "scenario2" });
sut.Clear();
sut.GetAll().Should().BeEmpty();
}
[Fact]
public void ContainsKey_ShouldBeCaseInsensitive()
{
var sut = CreateSut();
sut.TryAdd("Scenario1", new ScenarioState { Name = "Scenario1" });
sut.ContainsKey("scenario1").Should().BeTrue();
sut.ContainsKey("SCENARIO1").Should().BeTrue();
}
[Fact]
public void TryGet_ShouldBeCaseInsensitive()
{
var sut = CreateSut();
sut.TryAdd("Scenario1", new ScenarioState { Name = "Scenario1", NextState = "state2" });
sut.TryGet("scenario1", out var result1).Should().BeTrue();
result1!.NextState.Should().Be("state2");
sut.TryGet("SCENARIO1", out var result2).Should().BeTrue();
result2!.NextState.Should().Be("state2");
}
// --- File-persistence-specific tests ---
[Fact]
public void TryAdd_ShouldCreateJsonFileOnDisk()
{
var sut = CreateSut();
sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1", NextState = "state2" });
var filePath = Path.Combine(_scenariosFolder, "scenario1.json");
File.Exists(filePath).Should().BeTrue();
var json = File.ReadAllText(filePath);
var deserialized = JsonConvert.DeserializeObject<ScenarioState>(json);
deserialized!.Name.Should().Be("scenario1");
deserialized.NextState.Should().Be("state2");
}
[Fact]
public void TryRemove_ShouldDeleteJsonFileFromDisk()
{
var sut = CreateSut();
sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" });
var filePath = Path.Combine(_scenariosFolder, "scenario1.json");
File.Exists(filePath).Should().BeTrue();
sut.TryRemove("scenario1");
File.Exists(filePath).Should().BeFalse();
}
[Fact]
public void Clear_ShouldDeleteAllJsonFilesFromDisk()
{
var sut = CreateSut();
sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" });
sut.TryAdd("scenario2", new ScenarioState { Name = "scenario2" });
Directory.GetFiles(_scenariosFolder, "*.json").Should().HaveCount(2);
sut.Clear();
Directory.GetFiles(_scenariosFolder, "*.json").Should().BeEmpty();
}
[Fact]
public void Constructor_ShouldLoadExistingScenariosFromDisk()
{
// Pre-write JSON files before constructing the store
Directory.CreateDirectory(_scenariosFolder);
var state1 = new ScenarioState { Name = "scenario1", NextState = "loaded1" };
var state2 = new ScenarioState { Name = "scenario2", NextState = "loaded2", Counter = 3 };
File.WriteAllText(Path.Combine(_scenariosFolder, "scenario1.json"), JsonConvert.SerializeObject(state1));
File.WriteAllText(Path.Combine(_scenariosFolder, "scenario2.json"), JsonConvert.SerializeObject(state2));
var sut = CreateSut();
sut.GetAll().Should().HaveCount(2);
sut.TryGet("scenario1", out var loaded1).Should().BeTrue();
loaded1!.NextState.Should().Be("loaded1");
sut.TryGet("scenario2", out var loaded2).Should().BeTrue();
loaded2!.Counter.Should().Be(3);
}
[Fact]
public void Update_ShouldPersistChangesToDisk()
{
var sut = CreateSut();
sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1", Counter = 0 });
sut.Update("scenario1", s => { s.Counter = 10; s.NextState = "persisted"; });
var filePath = Path.Combine(_scenariosFolder, "scenario1.json");
var json = File.ReadAllText(filePath);
var deserialized = JsonConvert.DeserializeObject<ScenarioState>(json);
deserialized!.Counter.Should().Be(10);
deserialized.NextState.Should().Be("persisted");
}
}

View File

@@ -0,0 +1,157 @@
// Copyright © WireMock.Net
using WireMock.Handlers;
using Xunit;
namespace WireMock.Net.Tests.Handlers;
public class InMemoryScenarioStateStoreTests
{
private readonly InMemoryScenarioStateStore _sut = new();
[Fact]
public void TryAdd_ShouldAddNewScenario()
{
var state = new ScenarioState { Name = "scenario1" };
_sut.TryAdd("scenario1", state).Should().BeTrue();
_sut.ContainsKey("scenario1").Should().BeTrue();
}
[Fact]
public void TryAdd_ShouldReturnFalse_WhenScenarioAlreadyExists()
{
var state = new ScenarioState { Name = "scenario1" };
_sut.TryAdd("scenario1", state);
_sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" }).Should().BeFalse();
}
[Fact]
public void TryGet_ShouldReturnTrue_WhenExists()
{
var state = new ScenarioState { Name = "scenario1", NextState = "state2" };
_sut.TryAdd("scenario1", state);
_sut.TryGet("scenario1", out var result).Should().BeTrue();
result.Should().NotBeNull();
result!.NextState.Should().Be("state2");
}
[Fact]
public void TryGet_ShouldReturnFalse_WhenNotExists()
{
_sut.TryGet("nonexistent", out var result).Should().BeFalse();
result.Should().BeNull();
}
[Fact]
public void GetAll_ShouldReturnAllScenarios()
{
_sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" });
_sut.TryAdd("scenario2", new ScenarioState { Name = "scenario2" });
var result = _sut.GetAll();
result.Should().HaveCount(2);
}
[Fact]
public void GetAll_ShouldReturnEmpty_WhenNoScenarios()
{
_sut.GetAll().Should().BeEmpty();
}
[Fact]
public void Update_ShouldModifyExistingScenario()
{
_sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1", Counter = 0 });
var result = _sut.Update("scenario1", s => { s.Counter = 5; s.NextState = "state2"; });
result.Should().NotBeNull();
result!.Counter.Should().Be(5);
result.NextState.Should().Be("state2");
}
[Fact]
public void Update_ShouldReturnNull_WhenNotExists()
{
_sut.Update("nonexistent", s => { s.Counter = 5; }).Should().BeNull();
}
[Fact]
public void AddOrUpdate_ShouldAddNewScenario()
{
var result = _sut.AddOrUpdate(
"scenario1",
_ => new ScenarioState { Name = "scenario1", NextState = "added" },
(_, current) => { current.NextState = "updated"; return current; }
);
result.NextState.Should().Be("added");
}
[Fact]
public void AddOrUpdate_ShouldUpdateExistingScenario()
{
_sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1", NextState = "initial" });
var result = _sut.AddOrUpdate(
"scenario1",
_ => new ScenarioState { Name = "scenario1", NextState = "added" },
(_, current) => { current.NextState = "updated"; return current; }
);
result.NextState.Should().Be("updated");
}
[Fact]
public void TryRemove_ShouldRemoveExistingScenario()
{
_sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" });
_sut.TryRemove("scenario1").Should().BeTrue();
_sut.ContainsKey("scenario1").Should().BeFalse();
}
[Fact]
public void TryRemove_ShouldReturnFalse_WhenNotExists()
{
_sut.TryRemove("nonexistent").Should().BeFalse();
}
[Fact]
public void Clear_ShouldRemoveAllScenarios()
{
_sut.TryAdd("scenario1", new ScenarioState { Name = "scenario1" });
_sut.TryAdd("scenario2", new ScenarioState { Name = "scenario2" });
_sut.Clear();
_sut.GetAll().Should().BeEmpty();
}
[Fact]
public void ContainsKey_ShouldBeCaseInsensitive()
{
_sut.TryAdd("Scenario1", new ScenarioState { Name = "Scenario1" });
_sut.ContainsKey("scenario1").Should().BeTrue();
_sut.ContainsKey("SCENARIO1").Should().BeTrue();
}
[Fact]
public void TryGet_ShouldBeCaseInsensitive()
{
_sut.TryAdd("Scenario1", new ScenarioState { Name = "Scenario1", NextState = "state2" });
_sut.TryGet("scenario1", out var result1).Should().BeTrue();
result1!.NextState.Should().Be("state2");
_sut.TryGet("SCENARIO1", out var result2).Should().BeTrue();
result2!.NextState.Should().Be("state2");
}
}

View File

@@ -27,7 +27,7 @@ public class HttpRequestMessageHelperTests
var message = HttpRequestMessageHelper.Create(request, "http://url");
// Assert
message.Headers.GetValues("x").Should().Equal(new[] { "value-1" });
message.Headers.GetValues("x").Should().Equal(["value-1"]);
}
[Fact]
@@ -101,7 +101,7 @@ public class HttpRequestMessageHelperTests
// Assert
(await message.Content!.ReadAsStringAsync(_ct)).Should().Be("{\"x\":42}");
message.Content.Headers.GetValues("Content-Type").Should().Equal(new[] { "application/json" });
message.Content.Headers.GetValues("Content-Type").Should().Equal(["application/json"]);
}
[Fact]
@@ -121,7 +121,7 @@ public class HttpRequestMessageHelperTests
// Assert
(await message.Content!.ReadAsStringAsync(_ct)).Should().Be("{\"x\":42}");
message.Content.Headers.GetValues("Content-Type").Should().Equal(new[] { "application/json; charset=utf-8" });
message.Content.Headers.GetValues("Content-Type").Should().Equal(["application/json; charset=utf-8"]);
}
[Fact]
@@ -142,7 +142,7 @@ public class HttpRequestMessageHelperTests
// Assert
(await message.Content!.ReadAsStringAsync(_ct)).Should().Be("{\"x\":42}");
message.Content.Headers.GetValues("Content-Type").Should().Equal(new[] { "multipart/form-data" });
message.Content.Headers.GetValues("Content-Type").Should().Equal(["multipart/form-data"]);
}
@@ -162,7 +162,7 @@ public class HttpRequestMessageHelperTests
var message = HttpRequestMessageHelper.Create(request, "http://url");
// Assert
message.Content!.Headers.GetValues("Content-Type").Should().Equal(new[] { "application/xml" });
message.Content!.Headers.GetValues("Content-Type").Should().Equal(["application/xml"]);
}
[Fact]
@@ -181,7 +181,7 @@ public class HttpRequestMessageHelperTests
var message = HttpRequestMessageHelper.Create(request, "http://url");
// Assert
message.Content!.Headers.GetValues("Content-Type").Should().Equal(new[] { "application/xml; charset=UTF-8" });
message.Content!.Headers.GetValues("Content-Type").Should().Equal(["application/xml; charset=UTF-8"]);
}
[Fact]
@@ -200,7 +200,7 @@ public class HttpRequestMessageHelperTests
var message = HttpRequestMessageHelper.Create(request, "http://url");
// Assert
message.Content!.Headers.GetValues("Content-Type").Should().Equal(new[] { "application/xml; charset=Ascii" });
message.Content!.Headers.GetValues("Content-Type").Should().Equal(["application/xml; charset=Ascii"]);
}
[Fact]
@@ -242,7 +242,7 @@ public class HttpRequestMessageHelperTests
// Assert
(await message.Content!.ReadAsStringAsync(_ct)).Should().Be(body);
message.Content.Headers.GetValues("Content-Type").Should().Equal(new[] { "multipart/form-data" });
message.Content.Headers.GetValues("Content-Type").Should().Equal(["multipart/form-data"]);
}
[Theory]
@@ -269,7 +269,4 @@ public class HttpRequestMessageHelperTests
// Assert
message.Content?.Headers.ContentLength.Should().Be(resultShouldBe ? value : null);
}
}
}

View File

@@ -9,6 +9,8 @@ using WireMock.Util;
using WireMock.Owin;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using JsonConverter.Newtonsoft.Json;
using JsonConverter.System.Text.Json;
namespace WireMock.Net.Tests.Owin.Mappers;
@@ -34,6 +36,7 @@ public class OwinResponseMapperTests
_optionsMock = new Mock<IWireMockMiddlewareOptions>();
_optionsMock.SetupAllProperties();
_optionsMock.SetupGet(o => o.FileSystemHandler).Returns(_fileSystemHandlerMock.Object);
_optionsMock.SetupGet(o => o.DefaultJsonSerializer).Returns(new NewtonsoftJsonConverter());
_headers = new Mock<IHeaderDictionary>();
_headers.SetupAllProperties();
@@ -186,7 +189,7 @@ public class OwinResponseMapperTests
}
[Fact]
public async Task OwinResponseMapper_MapAsync_BodyAsJson()
public async Task OwinResponseMapper_MapAsync_BodyAsJson_UsingNewtonsoftJson()
{
// Arrange
var json = new { t = "x", i = (string?)null };
@@ -203,6 +206,25 @@ public class OwinResponseMapperTests
_stream.Verify(s => s.WriteAsync(new byte[] { 123, 34, 116, 34, 58, 34, 120, 34, 125 }, 0, 9, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task OwinResponseMapper_MapAsync_BodyAsJson_UsingSystemTextJson()
{
// Arrange
var json = new { t = "x", i = (string?)null };
var responseMessage = new ResponseMessage
{
Headers = new Dictionary<string, WireMockList<string>>(),
BodyData = new BodyData { DetectedBodyType = BodyType.Json, BodyAsJson = json, BodyAsJsonIndented = false }
};
_optionsMock.SetupGet(o => o.DefaultJsonSerializer).Returns(new SystemTextJsonConverter());
// Act
await _sut.MapAsync(responseMessage, _responseMock.Object);
// Assert
_stream.Verify(s => s.WriteAsync(new byte[] { 123, 34, 116, 34, 58, 34, 120, 34, 125 }, 0, 9, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task OwinResponseMapper_MapAsync_SetResponseHeaders()
{

View File

@@ -2,6 +2,7 @@
using System.Collections.Concurrent;
using Moq;
using WireMock.Handlers;
using WireMock.Logging;
using WireMock.Matchers.Request;
using WireMock.Models;
@@ -23,7 +24,7 @@ public class MappingMatcherTests
_optionsMock.SetupAllProperties();
_optionsMock.Setup(o => o.Mappings).Returns(new ConcurrentDictionary<Guid, IMapping>());
_optionsMock.Setup(o => o.LogEntries).Returns([]);
_optionsMock.Setup(o => o.Scenarios).Returns(new ConcurrentDictionary<string, ScenarioState>());
_optionsMock.Setup(o => o.ScenarioStateStore).Returns(new InMemoryScenarioStateStore());
var loggerMock = new Mock<IWireMockLogger>();
loggerMock.SetupAllProperties();

View File

@@ -55,7 +55,7 @@ public class WireMockMiddlewareTests
_optionsMock.SetupAllProperties();
_optionsMock.Setup(o => o.Mappings).Returns(_mappings);
_optionsMock.Setup(o => o.LogEntries).Returns(new ConcurrentObservableCollection<LogEntry>());
_optionsMock.Setup(o => o.Scenarios).Returns(new ConcurrentDictionary<string, ScenarioState>());
_optionsMock.Setup(o => o.ScenarioStateStore).Returns(new InMemoryScenarioStateStore());
_optionsMock.Setup(o => o.Logger.Warn(It.IsAny<string>(), It.IsAny<object[]>()));
_optionsMock.Setup(o => o.Logger.Error(It.IsAny<string>(), It.IsAny<object[]>()));
_optionsMock.Setup(o => o.Logger.DebugRequestResponse(It.IsAny<LogEntryModel>(), It.IsAny<bool>()));

View File

@@ -3,6 +3,8 @@
using JsonConverter.Newtonsoft.Json;
using WireMock.Admin.Mappings;
using WireMock.Serialization;
using Newtonsoft.Json.Linq;
#if NET8_0_OR_GREATER
using JsonConverter.System.Text.Json;
#endif
@@ -319,5 +321,92 @@ public class MappingSerializerTests
act.Should().Throw<InvalidOperationException>()
.WithMessage("Cannot deserialize the provided value to an array or object.");
}
[Fact]
public void MappingSerializer_DeserializeJsonToArray_WithNewtonsoftJson_DateTimeStringInQueryParamExactMatcherPattern_ShouldPreservePatternAsString()
{
// Arrange
var jsonConverter = new NewtonsoftJsonConverter();
var serializer = new MappingSerializer(jsonConverter);
var mappingJson =
"""
{
"Guid": "12345678-1234-1234-1234-aaaaaaaaaaaa",
"Request": {
"Path": "/api/report",
"Methods": ["GET"],
"Params": [
{
"Name": "asOfDate",
"Matchers": [
{
"Name": "ExactMatcher",
"Pattern": "2021-11-10T13:39:13.705"
}
]
}
]
},
"Response": {
"StatusCode": 200
}
}
""";
// Act
var result = serializer.DeserializeJsonToArray<MappingModel>(mappingJson);
// Assert
result.Should().HaveCount(1);
var matcher = result[0].Request!.Params![0].Matchers![0];
matcher.Name.Should().Be("ExactMatcher");
matcher.Pattern.Should().BeOfType<string>()
.Which.Should().Be("2021-11-10T13:39:13.705",
"datetime-format strings in ExactMatcher Pattern fields must survive deserialization as strings, " +
"not be auto-converted to DateTime by Newtonsoft.Json's DateParseHandling.DateTime");
}
[Fact]
public void MappingSerializer_DeserializeJsonToArray_WithNewtonsoftJson_DateTimeStringInJsonMatcherBodyPattern_ShouldPreservePatternAsString()
{
// Arrange
var jsonConverter = new NewtonsoftJsonConverter();
var serializer = new MappingSerializer(jsonConverter);
// Pattern is an INLINE JSON object (not a string) - this is how WireMock mapping files store
// JsonMatcher patterns when recorded. Newtonsoft with DateParseHandling.DateTime will convert
// the datetime value inside the JObject to JTokenType.Date during deserialization.
var mappingJson =
"""
{
"Guid": "12345678-1234-1234-1234-bbbbbbbbbbbb",
"Request": {
"Path": "/api/report",
"Methods": ["POST"],
"Body": {
"Matcher": {
"Name": "JsonMatcher",
"Pattern": {"Date": "2021-09-30T00:00:00Z", "Names": ["Cash"]}
}
}
},
"Response": {
"StatusCode": 200
}
}
""";
// Act
var result = serializer.DeserializeJsonToArray<MappingModel>(mappingJson);
// Assert - datetime values inside the JObject pattern must remain JTokenType.String.
result.Should().HaveCount(1);
var matcher = result[0].Request!.Body!.Matcher!;
matcher.Name.Should().Be("JsonMatcher");
var patternJObject = matcher.Pattern.Should().BeOfType<JObject>().Subject;
patternJObject["Date"]!.Type.Should().Be(JTokenType.String,
"datetime-format strings inside an inline JsonMatcher body pattern must retain JTokenType.String " +
"after deserialization; if DateParseHandling.DateTime auto-converts them to JTokenType.Date, " +
"JToken.DeepEquals will fail against incoming request bodies parsed with DateParseHandling.None");
}
#endif
}

View File

@@ -325,26 +325,26 @@ public class StatefulBehaviorTests
var getResponse1 = await client.GetStringAsync("/todo/items", cancelationToken);
getResponse1.Should().Be("Buy milk");
server.Scenarios["To do list"].Name.Should().Be("To do list");
server.Scenarios["To do list"].NextState.Should().Be("TodoList State Started");
server.Scenarios["To do list"].Started.Should().BeTrue();
server.Scenarios["To do list"].Finished.Should().BeFalse();
server.Scenarios.First(s => s.Name == "To do list").Name.Should().Be("To do list");
server.Scenarios.First(s => s.Name == "To do list").NextState.Should().Be("TodoList State Started");
server.Scenarios.First(s => s.Name == "To do list").Started.Should().BeTrue();
server.Scenarios.First(s => s.Name == "To do list").Finished.Should().BeFalse();
var postResponse = await client.PostAsync("/todo/items", new StringContent("Cancel newspaper subscription"), cancelationToken);
postResponse.StatusCode.Should().Be(HttpStatusCode.Created);
server.Scenarios["To do list"].Name.Should().Be("To do list");
server.Scenarios["To do list"].NextState.Should().Be("Cancel newspaper item added");
server.Scenarios["To do list"].Started.Should().BeTrue();
server.Scenarios["To do list"].Finished.Should().BeFalse();
server.Scenarios.First(s => s.Name == "To do list").Name.Should().Be("To do list");
server.Scenarios.First(s => s.Name == "To do list").NextState.Should().Be("Cancel newspaper item added");
server.Scenarios.First(s => s.Name == "To do list").Started.Should().BeTrue();
server.Scenarios.First(s => s.Name == "To do list").Finished.Should().BeFalse();
string getResponse2 = await client.GetStringAsync("/todo/items", cancelationToken);
getResponse2.Should().Be("Buy milk;Cancel newspaper subscription");
server.Scenarios["To do list"].Name.Should().Be("To do list");
server.Scenarios["To do list"].NextState.Should().BeNull();
server.Scenarios["To do list"].Started.Should().BeTrue();
server.Scenarios["To do list"].Finished.Should().BeTrue();
server.Scenarios.First(s => s.Name == "To do list").Name.Should().Be("To do list");
server.Scenarios.First(s => s.Name == "To do list").NextState.Should().BeNull();
server.Scenarios.First(s => s.Name == "To do list").Started.Should().BeTrue();
server.Scenarios.First(s => s.Name == "To do list").Finished.Should().BeTrue();
server.Stop();
}
@@ -372,14 +372,14 @@ public class StatefulBehaviorTests
// Act and Assert
server.SetScenarioState(scenario, "Buy milk");
server.Scenarios[scenario].Should().BeEquivalentTo(new { Name = scenario, NextState = "Buy milk" });
server.Scenarios.First(s => s.Name == scenario).Should().BeEquivalentTo(new { Name = scenario, NextState = "Buy milk" });
var getResponse1 = await client.GetStringAsync("/todo/items", cancelationToken);
getResponse1.Should().Be("Buy milk");
server.SetScenarioState(scenario, "Cancel newspaper");
server.Scenarios[scenario].Name.Should().Be(scenario);
server.Scenarios[scenario].Should().BeEquivalentTo(new { Name = scenario, NextState = "Cancel newspaper" });
server.Scenarios.First(s => s.Name == scenario).Name.Should().Be(scenario);
server.Scenarios.First(s => s.Name == scenario).Should().BeEquivalentTo(new { Name = scenario, NextState = "Cancel newspaper" });
var getResponse2 = await client.GetStringAsync("/todo/items", cancelationToken);
getResponse2.Should().Be("Buy milk;Cancel newspaper subscription");
@@ -413,6 +413,120 @@ public class StatefulBehaviorTests
action.Should().ThrowAsync<HttpRequestException>();
}
[Fact]
public async Task Scenarios_FirstRequestWithDelay_StateTransitions_BeforeDelayCompletes()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
var path = $"/foo_{Guid.NewGuid()}";
using var server = WireMockServer.Start();
// Mapping 1: start state, has a 500 ms delay
server
.Given(Request.Create().WithPath(path).UsingGet())
.InScenario("1260")
.WillSetStateTo("State1")
.RespondWith(Response.Create()
.WithBody("delayed response")
.WithDelay(TimeSpan.FromMilliseconds(500)));
// Mapping 2: only matches after state has transitioned to "State1"
server
.Given(Request.Create().WithPath(path).UsingGet())
.InScenario("1260")
.WhenStateIs("State1")
.RespondWith(Response.Create().WithBody("immediate response"));
var client = new HttpClient();
// 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);
// 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.
var response2 = await client.GetStringAsync(server.Url + path, cancellationToken);
var response1 = await request1Task;
// Assert
response1.Should().Be("delayed response");
response2.Should().Be("immediate response");
}
[Fact]
public async Task Scenarios_WithGlobalRequestProcessingDelay_StateTransitions_BeforeDelayCompletes()
{
// Arrange: use the global RequestProcessingDelay instead of a per-mapping delay
var cancellationToken = TestContext.Current.CancellationToken;
var path = $"/foo_{Guid.NewGuid()}";
using var server = WireMockServer.Start();
server.AddGlobalProcessingDelay(TimeSpan.FromMilliseconds(500));
server
.Given(Request.Create().WithPath(path).UsingGet())
.InScenario("s")
.WillSetStateTo("State1")
.RespondWith(Response.Create().WithBody("delayed response"));
server
.Given(Request.Create().WithPath(path).UsingGet())
.InScenario("s")
.WhenStateIs("State1")
.RespondWith(Response.Create().WithBody("immediate response"));
var client = new HttpClient();
// Act
var request1Task = client.GetStringAsync(server.Url + path, cancellationToken);
await Task.Delay(100, cancellationToken);
var response2 = await client.GetStringAsync(server.Url + path, cancellationToken);
var response1 = await request1Task;
// Assert
response1.Should().Be("delayed response");
response2.Should().Be("immediate response");
}
[Fact]
public async Task Scenarios_WithDelay_And_TimesInSameState_Should_Transition_After_Required_Hits()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
var path = $"/foo_{Guid.NewGuid()}";
using var server = WireMockServer.Start();
// Mapping 1: requires 2 hits before transitioning; has a short delay
server
.Given(Request.Create().WithPath(path).UsingGet())
.InScenario("s")
.WillSetStateTo("State1", 2)
.RespondWith(Response.Create()
.WithBody("first")
.WithDelay(TimeSpan.FromMilliseconds(50)));
// Mapping 2: matches after state is "State1"
server
.Given(Request.Create().WithPath(path).UsingGet())
.InScenario("s")
.WhenStateIs("State1")
.RespondWith(Response.Create().WithBody("second"));
var client = new HttpClient();
// Act
var response1 = await client.GetStringAsync(server.Url + path, cancellationToken);
var response2 = await client.GetStringAsync(server.Url + path, cancellationToken);
var response3 = await client.GetStringAsync(server.Url + path, cancellationToken);
// Assert: state only transitions after 2 hits, so request 3 is the first to match Mapping 2
response1.Should().Be("first");
response2.Should().Be("first");
response3.Should().Be("second");
}
[Fact]
public async Task Scenarios_Should_process_request_if_equals_state_and_multiple_state_defined()
{

View File

@@ -706,7 +706,7 @@ public class WebSocketIntegrationTests(ITestOutputHelper output, ITestContextAcc
public async Task WithWebSocketProxy_Should_Proxy_Multiple_TextMessages()
{
// Arrange - Start target echo server
using var exampleEchoServer = WireMockServer.Start(new WireMockServerSettings
var exampleEchoServer = WireMockServer.Start(new WireMockServerSettings
{
Logger = new TestOutputHelperWireMockLogger(output),
Urls = ["ws://localhost:0"]
@@ -722,7 +722,7 @@ public class WebSocketIntegrationTests(ITestOutputHelper output, ITestContextAcc
);
// Arrange - Start proxy server
using var sut = WireMockServer.Start(new WireMockServerSettings
var sut = WireMockServer.Start(new WireMockServerSettings
{
Logger = new TestOutputHelperWireMockLogger(output),
Urls = ["ws://localhost:0"]
@@ -755,13 +755,21 @@ public class WebSocketIntegrationTests(ITestOutputHelper output, ITestContextAcc
}
await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "Test complete", _ct);
await Task.Delay(250, _ct);
sut.Stop();
sut.Dispose();
exampleEchoServer.Stop();
exampleEchoServer.Dispose();
}
[Fact]
public async Task WithWebSocketProxy_Should_Proxy_Binary_Messages()
{
// Arrange - Start target echo server
using var exampleEchoServer = WireMockServer.Start(new WireMockServerSettings
var exampleEchoServer = WireMockServer.Start(new WireMockServerSettings
{
Logger = new TestOutputHelperWireMockLogger(output),
Urls = ["ws://localhost:0"]
@@ -777,7 +785,7 @@ public class WebSocketIntegrationTests(ITestOutputHelper output, ITestContextAcc
);
// Arrange - Start proxy server
using var sut = WireMockServer.Start(new WireMockServerSettings
var sut = WireMockServer.Start(new WireMockServerSettings
{
Logger = new TestOutputHelperWireMockLogger(output),
Urls = ["ws://localhost:0"]
@@ -811,6 +819,14 @@ public class WebSocketIntegrationTests(ITestOutputHelper output, ITestContextAcc
receivedData.Should().BeEquivalentTo(testData, "binary data should be proxied and echoed back");
await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "Test complete", _ct);
await Task.Delay(250, _ct);
sut.Stop();
sut.Dispose();
exampleEchoServer.Stop();
exampleEchoServer.Dispose();
}
[Fact]

View File

@@ -4,10 +4,13 @@ using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json.Serialization;
using JsonConverter.System.Text.Json;
using WireMock.Matchers;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using WireMock.Settings;
namespace WireMock.Net.Tests;
@@ -18,11 +21,41 @@ public partial class WireMockServerTests
public string? Hi { get; set; }
}
public class Person
{
[JsonPropertyName("first_name")]
public string FirstName { get; set; } = string.Empty;
[JsonPropertyName("last_name")]
public string LastName { get; set; } = string.Empty;
}
[Fact]
public async Task WireMockServer_WithBodyAsJson_UsingWireMockServerSettings_SystemTextJsonConverter_ShouldReturnCorrectResponse()
{
// Arange
var person = new Person { FirstName = "John", LastName = "Smith" };
using var server = WireMockServer.Start(new WireMockServerSettings
{
DefaultJsonSerializer = new SystemTextJsonConverter()
});
// Act
server
.Given(Request.Create().UsingAnyMethod())
.RespondWith(Response.Create().WithBodyAsJson(person));
var response = await server.CreateClient().GetStringAsync("/", _ct);
// Assert
response.Should().BeEquivalentTo("{\"first_name\":\"John\",\"last_name\":\"Smith\"}");
}
[Fact]
public async Task WireMockServer_WithBodyAsJson_Using_PostAsJsonAsync_And_MultipleJmesPathMatchers_ShouldMatch()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = WireMockServer.Start();
server.Given(
Request.Create()
@@ -56,7 +89,7 @@ public partial class WireMockServerTests
var requestUri = new Uri($"http://localhost:{server.Port}/a");
var json = new { requestId = "1", value = "A" };
var response = await server.CreateClient().PostAsJsonAsync(requestUri, json, cancellationToken);
var response = await server.CreateClient().PostAsJsonAsync(requestUri, json, _ct);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -68,7 +101,6 @@ public partial class WireMockServerTests
public async Task WireMockServer_WithBodyAsJson_Using_PostAsJsonAsync_And_MultipleJmesPathMatchers_ShouldMatch_BestMatching()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = WireMockServer.Start();
server.Given(
Request.Create()
@@ -105,7 +137,7 @@ public partial class WireMockServerTests
var requestUri = new Uri($"http://localhost:{server.Port}/a");
var json = new { extra = "X", requestId = "1", value = "A" };
var response = await server.CreateClient().PostAsJsonAsync(requestUri, json, cancellationToken);
var response = await server.CreateClient().PostAsJsonAsync(requestUri, json, _ct);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -117,7 +149,6 @@ public partial class WireMockServerTests
public async Task WireMockServer_WithBodyAsJson_Using_PostAsJsonAsync_And_WildcardMatcher_ShouldMatch()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = WireMockServer.Start();
server.Given(
Request.Create().UsingPost().WithPath("/foo").WithBody(new WildcardMatcher("*Hello*"))
@@ -132,7 +163,7 @@ public partial class WireMockServerTests
};
// Act
var response = await new HttpClient().PostAsJsonAsync("http://localhost:" + server.Ports[0] + "/foo", jsonObject, cancellationToken);
var response = await new HttpClient().PostAsJsonAsync("http://localhost:" + server.Ports[0] + "/foo", jsonObject, _ct);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -144,7 +175,6 @@ public partial class WireMockServerTests
public async Task WireMockServer_WithBodyAsJson_Using_PostAsync_And_WildcardMatcher_ShouldMatch()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = WireMockServer.Start();
server.Given(
Request.Create().UsingPost().WithPath("/foo").WithBody(new WildcardMatcher("*Hello*"))
@@ -154,7 +184,7 @@ public partial class WireMockServerTests
);
// Act
var response = await new HttpClient().PostAsync("http://localhost:" + server.Ports[0] + "/foo", new StringContent("{ Hi = \"Hello World\" }"), cancellationToken);
var response = await new HttpClient().PostAsync("http://localhost:" + server.Ports[0] + "/foo", new StringContent("{ Hi = \"Hello World\" }"), _ct);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -166,7 +196,6 @@ public partial class WireMockServerTests
public async Task WireMockServer_WithBodyAsJson_Using_PostAsync_And_JsonPartialWildcardMatcher_ShouldMatch()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = WireMockServer.Start();
var matcher = new JsonPartialWildcardMatcher(new { method = "initialize", id = "^[a-f0-9]{32}-[0-9]$" }, ignoreCase: true, regex: true);
@@ -188,13 +217,13 @@ public partial class WireMockServerTests
// Act
var content = "{\"jsonrpc\":\"2.0\",\"id\":\"ec475f56d4694b48bc737500ba575b35-1\",\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"GitHub Test\",\"version\":\"1.0.0\"}}}";
var response = await new HttpClient()
.PostAsync($"{server.Url}/foo", new StringContent(content, Encoding.UTF8, "application/json"), cancellationToken)
.PostAsync($"{server.Url}/foo", new StringContent(content, Encoding.UTF8, "application/json"), _ct)
;
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
var responseText = await response.Content.ReadAsStringAsync(_ct);
responseText.Should().Contain("ec475f56d4694b48bc737500ba575b35-1");
}
@@ -203,8 +232,7 @@ public partial class WireMockServerTests
public async Task WireMockServer_WithBodyAsJson_Using_PostAsync_And_JsonPartialWildcardMatcher_And_SystemTextJson_ShouldMatch()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = WireMockServer.Start(x => x.DefaultJsonSerializer = new JsonConverter.System.Text.Json.SystemTextJsonConverter() );
using var server = WireMockServer.Start(x => x.DefaultJsonSerializer = new SystemTextJsonConverter());
var matcher = new JsonPartialWildcardMatcher(new { id = "^[a-f0-9]{32}-[0-9]$" }, ignoreCase: true, regex: true);
server.Given(Request.Create()
@@ -220,12 +248,12 @@ public partial class WireMockServerTests
// Act
var content = """{"id":"ec475f56d4694b48bc737500ba575b35-1"}""";
using var httpClient = new HttpClient();
var response = await httpClient.PostAsync($"{server.Url}/system-text-json", new StringContent(content, Encoding.UTF8, "application/json"), cancellationToken);
var response = await httpClient.PostAsync($"{server.Url}/system-text-json", new StringContent(content, Encoding.UTF8, "application/json"), _ct);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
var responseText = await response.Content.ReadAsStringAsync(_ct);
responseText.Should().Contain("OK");
}
#endif
@@ -234,7 +262,6 @@ public partial class WireMockServerTests
public async Task WireMockServer_WithBodyAsFormUrlEncoded_Using_PostAsync_And_WithFunc()
{
// Arrange
var cancelationToken = TestContext.Current.CancellationToken;
using var server = WireMockServer.Start();
server.Given(
Request.Create()
@@ -249,7 +276,7 @@ public partial class WireMockServerTests
// Act
var content = new FormUrlEncodedContent([new KeyValuePair<string, string>("key1", "value1")]);
var response = await new HttpClient()
.PostAsync($"{server.Url}/foo", content, cancelationToken);
.PostAsync($"{server.Url}/foo", content, _ct);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -261,7 +288,6 @@ public partial class WireMockServerTests
public async Task WireMockServer_WithBodyAsFormUrlEncoded_Using_PostAsync_And_WithExactMatcher()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = WireMockServer.Start();
server.Given(
Request.Create()
@@ -282,7 +308,7 @@ public partial class WireMockServerTests
new KeyValuePair<string, string>("email", "johndoe@example.com")
]);
using var httpClient = new HttpClient();
var response = await httpClient.PostAsync($"{server.Url}/foo", content, cancellationToken)
var response = await httpClient.PostAsync($"{server.Url}/foo", content, _ct)
;
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -294,7 +320,6 @@ public partial class WireMockServerTests
public async Task WireMockServer_WithBodyAsFormUrlEncoded_Using_PostAsync_And_WithFormUrlEncodedMatcher()
{
// Arrange
var cancelationToken = TestContext.Current.CancellationToken;
var matcher = new FormUrlEncodedMatcher(["email=johndoe@example.com", "name=John Doe"]);
using var server = WireMockServer.Start();
server.Given(
@@ -326,7 +351,7 @@ public partial class WireMockServerTests
new KeyValuePair<string, string>("email", "johndoe@example.com")
]);
var responseOrdered = await new HttpClient()
.PostAsync($"{server.Url}/foo", contentOrdered, cancelationToken)
.PostAsync($"{server.Url}/foo", contentOrdered, _ct)
;
// Assert 1
@@ -340,7 +365,7 @@ public partial class WireMockServerTests
new KeyValuePair<string, string>("name", "John Doe"),
]);
var responseUnordered = await new HttpClient()
.PostAsync($"{server.Url}/bar", contentUnordered, cancelationToken)
.PostAsync($"{server.Url}/bar", contentUnordered, _ct)
;
// Assert 2
@@ -353,7 +378,6 @@ public partial class WireMockServerTests
public async Task WireMockServer_WithSseBody()
{
// Arrange
var cancellationToken = TestContext.Current.CancellationToken;
using var server = WireMockServer.Start();
server
.WhenRequest(r => r
@@ -387,9 +411,8 @@ public partial class WireMockServerTests
using var client = new HttpClient();
// Act 1
var normal = await client.GetAsync(server.Url, cancellationToken)
;
(await normal.Content.ReadAsStringAsync(cancellationToken)).Should().Be("normal");
var normal = await client.GetAsync(server.Url, _ct);
(await normal.Content.ReadAsStringAsync(_ct)).Should().Be("normal");
// Act 2
using var response = await client.GetStreamAsync($"{server.Url}/sse", _ct);

View File

@@ -431,8 +431,13 @@ public partial class WireMockServerTests(ITestOutputHelper testOutputHelper)
using var server = WireMockServer.Start();
server
.Given(Request.Create().WithPath(path).UsingHead())
.RespondWith(Response.Create().WithHeader(HttpKnownHeaderNames.ContentLength, length));
.WhenRequest(r => r
.WithPath(path)
.UsingHead()
)
.ThenRespondWith(r => r
.WithHeader(HttpKnownHeaderNames.ContentLength, length)
);
// Act
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Head, path);
@@ -442,6 +447,45 @@ public partial class WireMockServerTests(ITestOutputHelper testOutputHelper)
response.Content.Headers.GetValues(HttpKnownHeaderNames.ContentLength).Should().Contain(length);
}
#if NET8_0_OR_GREATER
[Theory]
[InlineData("DELETE")]
[InlineData("GET")]
[InlineData("OPTIONS")]
[InlineData("PATCH")]
[InlineData("POST")]
[InlineData("PUT")]
[InlineData("TRACE")]
public async Task WireMockServer_Should_LogAndThrowExceptionWhenInvalidContentLength(string method)
{
// Assign
const string length = "42";
var path = $"/InvalidContentLength_{Guid.NewGuid()}";
using var server = WireMockServer.Start(new WireMockServerSettings
{
Logger = new TestOutputHelperWireMockLogger(testOutputHelper)
});
server
.WhenRequest(r => r
.WithPath(path)
.UsingAnyMethod()
)
.ThenRespondWith(r => r
.WithStatusCode(HttpStatusCode.OK)
.WithHeader(HttpKnownHeaderNames.ContentLength, length)
);
// Act
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Parse(method), path);
var response = await server.CreateClient().SendAsync(httpRequestMessage, _ct);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
testOutputHelper.Output.Should().Contain($"Response Content-Length mismatch: too few bytes written (0 of {length}).");
}
#endif
[Theory]
[InlineData("TRACE")]
[InlineData("GET")]