Add FormUrlEncodedMatcher (#1147)

* FormUrlEncodedMatcher

* .

* Fix

* new

* support wildcard
This commit is contained in:
Stef Heyenrath
2024-07-27 14:40:23 +02:00
committed by GitHub
parent 926eaaece4
commit 3353be65b5
10 changed files with 530 additions and 30 deletions

View File

@@ -0,0 +1,153 @@
// Copyright © WireMock.Net
using System.Collections.Generic;
using AnyOfTypes;
using Stef.Validation;
using WireMock.Models;
using WireMock.Util;
namespace WireMock.Matchers;
/// <summary>
/// FormUrl Encoded fields Matcher
/// </summary>
/// <inheritdoc cref="IStringMatcher"/>
/// <inheritdoc cref="IIgnoreCaseMatcher"/>
public class FormUrlEncodedMatcher : IStringMatcher, IIgnoreCaseMatcher
{
private readonly AnyOf<string, StringPattern>[] _patterns;
/// <inheritdoc />
public MatchBehaviour MatchBehaviour { get; }
private readonly List<(WildcardMatcher Key, WildcardMatcher? Value)> _pairs = [];
/// <summary>
/// Initializes a new instance of the <see cref="FormUrlEncodedMatcher"/> class.
/// </summary>
/// <param name="pattern">The pattern.</param>
/// <param name="ignoreCase">Ignore the case from the pattern.</param>
/// <param name="matchOperator">The <see cref="Matchers.MatchOperator"/> to use. (default = "Or")</param>
public FormUrlEncodedMatcher(
AnyOf<string, StringPattern> pattern,
bool ignoreCase = false,
MatchOperator matchOperator = MatchOperator.Or) :
this(MatchBehaviour.AcceptOnMatch, [pattern], ignoreCase, matchOperator)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="FormUrlEncodedMatcher"/> class.
/// </summary>
/// <param name="matchBehaviour">The match behaviour.</param>
/// <param name="pattern">The pattern.</param>
/// <param name="ignoreCase">Ignore the case from the pattern.</param>
/// <param name="matchOperator">The <see cref="Matchers.MatchOperator"/> to use. (default = "Or")</param>
public FormUrlEncodedMatcher(
MatchBehaviour matchBehaviour,
AnyOf<string, StringPattern> pattern,
bool ignoreCase = false,
MatchOperator matchOperator = MatchOperator.Or) :
this(matchBehaviour, [pattern], ignoreCase, matchOperator)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="FormUrlEncodedMatcher"/> class.
/// </summary>
/// <param name="patterns">The patterns.</param>
/// <param name="ignoreCase">Ignore the case from the pattern.</param>
/// <param name="matchOperator">The <see cref="Matchers.MatchOperator"/> to use. (default = "Or")</param>
public FormUrlEncodedMatcher(
AnyOf<string, StringPattern>[] patterns,
bool ignoreCase = false,
MatchOperator matchOperator = MatchOperator.Or) :
this(MatchBehaviour.AcceptOnMatch, patterns, ignoreCase, matchOperator)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="FormUrlEncodedMatcher"/> class.
/// </summary>
/// <param name="matchBehaviour">The match behaviour.</param>
/// <param name="patterns">The patterns.</param>
/// <param name="ignoreCase">Ignore the case from the pattern.</param>
/// <param name="matchOperator">The <see cref="Matchers.MatchOperator"/> to use. (default = "Or")</param>
public FormUrlEncodedMatcher(
MatchBehaviour matchBehaviour,
AnyOf<string, StringPattern>[] patterns,
bool ignoreCase = false,
MatchOperator matchOperator = MatchOperator.Or)
{
_patterns = Guard.NotNull(patterns);
IgnoreCase = ignoreCase;
MatchBehaviour = matchBehaviour;
MatchOperator = matchOperator;
foreach (var pattern in _patterns)
{
if (QueryStringParser.TryParse(pattern, IgnoreCase, out var nameValueCollection))
{
foreach (var nameValue in nameValueCollection)
{
var keyMatcher = new WildcardMatcher(MatchBehaviour.AcceptOnMatch, [nameValue.Key], ignoreCase, MatchOperator);
var valueMatcher = new WildcardMatcher(MatchBehaviour.AcceptOnMatch, [nameValue.Value], ignoreCase, MatchOperator);
_pairs.Add((keyMatcher, valueMatcher));
}
}
}
}
/// <inheritdoc />
public MatchResult IsMatch(string? input)
{
// Input is null or empty and if no patterns defined, return Perfect match.
if (string.IsNullOrEmpty(input) && _patterns.Length == 0)
{
return new MatchResult(MatchScores.Perfect);
}
if (!QueryStringParser.TryParse(input, IgnoreCase, out var inputNameValueCollection))
{
return new MatchResult(MatchScores.Mismatch);
}
var matches = new List<bool>();
foreach (var inputKeyValuePair in inputNameValueCollection)
{
var match = false;
foreach (var pair in _pairs)
{
var keyMatchResult = pair.Key.IsMatch(inputKeyValuePair.Key).IsPerfect();
if (keyMatchResult)
{
match = pair.Value?.IsMatch(inputKeyValuePair.Value).IsPerfect() ?? false;
if (match)
{
break;
}
}
}
matches.Add(match);
}
var score = MatchScores.ToScore(matches.ToArray(), MatchOperator);
return new MatchResult(MatchBehaviourHelper.Convert(MatchBehaviour, score));
}
/// <inheritdoc />
public virtual AnyOf<string, StringPattern>[] GetPatterns()
{
return _patterns;
}
/// <inheritdoc />
public virtual string Name => nameof(FormUrlEncodedMatcher);
/// <inheritdoc />
public bool IgnoreCase { get; }
/// <inheritdoc />
public MatchOperator MatchOperator { get; }
}

View File

@@ -147,19 +147,22 @@ internal class MappingConverter
{
var firstMatcher = requestMessageBodyMatcher.Matchers.FirstOrDefault();
if (firstMatcher is WildcardMatcher wildcardMatcher && wildcardMatcher.GetPatterns().Any())
switch (firstMatcher)
{
sb.AppendLine($" .WithBody({GetString(wildcardMatcher)})");
}
case IStringMatcher stringMatcher when stringMatcher.GetPatterns().Length > 0:
sb.AppendLine($" .WithBody({GetString(stringMatcher)})");
break;
if (firstMatcher is JsonMatcher jsonMatcher)
{
var matcherType = jsonMatcher.GetType().Name;
sb.AppendLine($" .WithBody(new {matcherType}(");
sb.AppendLine($" value: {ConvertToAnonymousObjectDefinition(jsonMatcher.Value, 3)},");
sb.AppendLine($" ignoreCase: {ToCSharpBooleanLiteral(jsonMatcher.IgnoreCase)},");
sb.AppendLine($" regex: {ToCSharpBooleanLiteral(jsonMatcher.Regex)}");
sb.AppendLine(@" ))");
case JsonMatcher jsonMatcher:
{
var matcherType = jsonMatcher.GetType().Name;
sb.AppendLine($" .WithBody(new {matcherType}(");
sb.AppendLine($" value: {ConvertToAnonymousObjectDefinition(jsonMatcher.Value, 3)},");
sb.AppendLine($" ignoreCase: {ToCSharpBooleanLiteral(jsonMatcher.IgnoreCase)},");
sb.AppendLine($" regex: {ToCSharpBooleanLiteral(jsonMatcher.Regex)}");
sb.AppendLine(@" ))");
break;
}
}
}

View File

@@ -111,6 +111,9 @@ internal class MatcherMapper
case nameof(ContentTypeMatcher):
return new ContentTypeMatcher(matchBehaviour, stringPatterns, ignoreCase);
case nameof(FormUrlEncodedMatcher):
return new FormUrlEncodedMatcher(matchBehaviour, stringPatterns, ignoreCase);
case nameof(SimMetricsMatcher):
SimMetricType type = SimMetricType.Levenstein;
if (!string.IsNullOrEmpty(matcherType) && !Enum.TryParse(matcherType, out type))
@@ -224,7 +227,7 @@ internal class MatcherMapper
{
if (matcher.Pattern is string patternAsString)
{
return new[] { new AnyOf<string, StringPattern>(patternAsString) };
return [new AnyOf<string, StringPattern>(patternAsString)];
}
if (matcher.Pattern is IEnumerable<string> patternAsStringArray)
@@ -241,7 +244,7 @@ internal class MatcherMapper
{
var patternAsFile = matcher.PatternAsFile!;
var pattern = _settings.FileSystemHandler.ReadFileAsString(patternAsFile);
return new[] { new AnyOf<string, StringPattern>(new StringPattern { Pattern = pattern, PatternAsFile = patternAsFile }) };
return [new AnyOf<string, StringPattern>(new StringPattern { Pattern = pattern, PatternAsFile = patternAsFile })];
}
return EmptyArray<AnyOf<string, StringPattern>>.Value;

View File

@@ -1,6 +1,6 @@
[
{
Guid: Guid_1,
Guid: 41372914-1838-4c67-916b-b9aacdd096ce,
UpdatedAt: 2023-01-14 15:16:17,
Request: {
Path: {
@@ -33,7 +33,7 @@
}
},
{
Guid: Guid_2,
Guid: 98fae52e-76df-47d9-876f-2ee32e931002,
UpdatedAt: 2023-01-14 15:16:17,
Request: {
Path: {
@@ -61,5 +61,77 @@
}
},
Response: {}
},
{
Guid: 98fae52e-76df-47d9-876f-2ee32e931003,
UpdatedAt: 2023-01-14 15:16:17,
Request: {
Path: {
Matchers: [
{
Name: WildcardMatcher,
Pattern: /form-urlencoded,
IgnoreCase: false
}
]
},
Methods: [
POST
],
Headers: [
{
Name: Content-Type,
Matchers: [
{
Name: WildcardMatcher,
Pattern: application/x-www-form-urlencoded,
IgnoreCase: true
}
],
IgnoreCase: true
}
],
Body: {
Matcher: {
Name: FormUrlEncodedMatcher,
Patterns: [
name=John Doe,
email=johndoe@example.com
],
IgnoreCase: false,
MatchOperator: Or
}
}
},
Response: {}
},
{
Guid: 98fae52e-76df-47d9-876f-2ee32e931001,
UpdatedAt: 2023-01-14 15:16:17,
Request: {
Path: {
Matchers: [
{
Name: WildcardMatcher,
Pattern: /users/post1,
IgnoreCase: false
}
]
},
Methods: [
POST
],
Body: {
Matcher: {
Name: JsonMatcher,
Pattern: {
Request: Hello?
},
IgnoreCase: false,
Regex: false
}
}
},
Response: {}
}
]

View File

@@ -24,7 +24,35 @@ builder
regex: false
))
)
.WithGuid("98fae52e-76df-47d9-876f-2ee32e931d9b")
.WithGuid("98fae52e-76df-47d9-876f-2ee32e931002")
.RespondWith(Response.Create()
);
builder
.Given(Request.Create()
.UsingMethod("POST")
.WithPath("/form-urlencoded")
.WithHeader("Content-Type", "application/x-www-form-urlencoded", true)
.WithBody("name=John Doe")
)
.WithGuid("98fae52e-76df-47d9-876f-2ee32e931003")
.RespondWith(Response.Create()
);
builder
.Given(Request.Create()
.UsingMethod("POST")
.WithPath("/users/post1")
.WithBody(new JsonMatcher(
value: new
{
Request = "Hello?"
},
ignoreCase: false,
regex: false
))
)
.WithGuid("98fae52e-76df-47d9-876f-2ee32e931001")
.RespondWith(Response.Create()
);

View File

@@ -24,7 +24,35 @@ server
regex: false
))
)
.WithGuid("98fae52e-76df-47d9-876f-2ee32e931d9b")
.WithGuid("98fae52e-76df-47d9-876f-2ee32e931002")
.RespondWith(Response.Create()
);
server
.Given(Request.Create()
.UsingMethod("POST")
.WithPath("/form-urlencoded")
.WithHeader("Content-Type", "application/x-www-form-urlencoded", true)
.WithBody("name=John Doe")
)
.WithGuid("98fae52e-76df-47d9-876f-2ee32e931003")
.RespondWith(Response.Create()
);
server
.Given(Request.Create()
.UsingMethod("POST")
.WithPath("/users/post1")
.WithBody(new JsonMatcher(
value: new
{
Request = "Hello?"
},
ignoreCase: false,
regex: false
))
)
.WithGuid("98fae52e-76df-47d9-876f-2ee32e931001")
.RespondWith(Response.Create()
);

View File

@@ -1,6 +1,6 @@
[
{
Guid: Guid_1,
Guid: 41372914-1838-4c67-916b-b9aacdd096ce,
UpdatedAt: 2023-01-14T15:16:17,
Request: {
Path: {
@@ -33,7 +33,7 @@
}
},
{
Guid: Guid_2,
Guid: 98fae52e-76df-47d9-876f-2ee32e931002,
UpdatedAt: 2023-01-14T15:16:17,
Request: {
Path: {
@@ -60,5 +60,75 @@
}
}
}
},
{
Guid: 98fae52e-76df-47d9-876f-2ee32e931003,
UpdatedAt: 2023-01-14T15:16:17,
Request: {
Path: {
Matchers: [
{
Name: WildcardMatcher,
Pattern: /form-urlencoded,
IgnoreCase: false
}
]
},
Methods: [
POST
],
Headers: [
{
Name: Content-Type,
Matchers: [
{
Name: WildcardMatcher,
Pattern: application/x-www-form-urlencoded,
IgnoreCase: true
}
],
IgnoreCase: true
}
],
Body: {
Matcher: {
Name: FormUrlEncodedMatcher,
Patterns: [
name=John Doe,
email=johndoe@example.com
],
IgnoreCase: false,
MatchOperator: Or
}
}
}
},
{
Guid: 98fae52e-76df-47d9-876f-2ee32e931001,
UpdatedAt: 2023-01-14T15:16:17,
Request: {
Path: {
Matchers: [
{
Name: WildcardMatcher,
Pattern: /users/post1,
IgnoreCase: false
}
]
},
Methods: [
POST
],
Body: {
Matcher: {
Name: JsonMatcher,
Pattern: {
Request: Hello?
},
IgnoreCase: false,
Regex: false
}
}
}
}
]

View File

@@ -30,7 +30,6 @@ public class MappingBuilderTests
VerifySettings.Init();
}
private static readonly Guid NewGuid = new("98fae52e-76df-47d9-876f-2ee32e931d9b");
private const string MappingGuid = "41372914-1838-4c67-916b-b9aacdd096ce";
private static readonly DateTime UtcNow = new(2023, 1, 14, 15, 16, 17);
@@ -43,7 +42,8 @@ public class MappingBuilderTests
_fileSystemHandlerMock = new Mock<IFileSystemHandler>();
var guidUtilsMock = new Mock<IGuidUtils>();
guidUtilsMock.Setup(g => g.NewGuid()).Returns(NewGuid);
var startGuid = 1000;
guidUtilsMock.Setup(g => g.NewGuid()).Returns(() => new Guid($"98fae52e-76df-47d9-876f-2ee32e93{startGuid++}"));
var dateTimeUtilsMock = new Mock<IDateTimeUtils>();
dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(UtcNow);
@@ -95,6 +95,13 @@ public class MappingBuilderTests
country = "The Netherlands"
}))
).RespondWith(Response.Create());
_sut.Given(Request.Create()
.UsingPost()
.WithPath("/form-urlencoded")
.WithHeader("Content-Type", "application/x-www-form-urlencoded")
.WithBody(new FormUrlEncodedMatcher(["name=John Doe", "email=johndoe@example.com"]))
).RespondWith(Response.Create());
}
[Fact]
@@ -104,7 +111,7 @@ public class MappingBuilderTests
var mappings = _sut.GetMappings();
// Verify
return Verifier.Verify(mappings, VerifySettings);
return Verifier.Verify(mappings, VerifySettings).DontScrubGuids();
}
[Fact]
@@ -114,7 +121,7 @@ public class MappingBuilderTests
var json = _sut.ToJson();
// Verify
return Verifier.VerifyJson(json, VerifySettings);
return Verifier.VerifyJson(json, VerifySettings).DontScrubGuids();
}
[Fact]
@@ -124,7 +131,7 @@ public class MappingBuilderTests
var code = _sut.ToCSharpCode(MappingConverterType.Server);
// Verify
return Verifier.Verify(code, VerifySettings);
return Verifier.Verify(code, VerifySettings).DontScrubGuids();
}
[Fact]
@@ -134,7 +141,7 @@ public class MappingBuilderTests
var code = _sut.ToCSharpCode(MappingConverterType.Builder);
// Verify
return Verifier.Verify(code, VerifySettings);
return Verifier.Verify(code, VerifySettings).DontScrubGuids();
}
[Fact]
@@ -183,9 +190,9 @@ public class MappingBuilderTests
_sut.SaveMappingsToFolder(null);
// Verify
_fileSystemHandlerMock.Verify(fs => fs.GetMappingFolder(), Times.Exactly(2));
_fileSystemHandlerMock.Verify(fs => fs.FolderExists(mappingFolder), Times.Exactly(2));
_fileSystemHandlerMock.Verify(fs => fs.WriteMappingFile(It.IsAny<string>(), It.IsAny<string>()), Times.Exactly(2));
_fileSystemHandlerMock.Verify(fs => fs.GetMappingFolder(), Times.Exactly(4));
_fileSystemHandlerMock.Verify(fs => fs.FolderExists(mappingFolder), Times.Exactly(4));
_fileSystemHandlerMock.Verify(fs => fs.WriteMappingFile(It.IsAny<string>(), It.IsAny<string>()), Times.Exactly(4));
_fileSystemHandlerMock.VerifyNoOtherCalls();
}
@@ -201,8 +208,8 @@ public class MappingBuilderTests
// Verify
_fileSystemHandlerMock.Verify(fs => fs.GetMappingFolder(), Times.Never);
_fileSystemHandlerMock.Verify(fs => fs.FolderExists(path), Times.Exactly(2));
_fileSystemHandlerMock.Verify(fs => fs.WriteMappingFile(It.IsAny<string>(), It.IsAny<string>()), Times.Exactly(2));
_fileSystemHandlerMock.Verify(fs => fs.FolderExists(path), Times.Exactly(4));
_fileSystemHandlerMock.Verify(fs => fs.WriteMappingFile(It.IsAny<string>(), It.IsAny<string>()), Times.Exactly(4));
_fileSystemHandlerMock.VerifyNoOtherCalls();
}
}

View File

@@ -0,0 +1,78 @@
// Copyright © WireMock.Net
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using AnyOfTypes;
using FluentAssertions;
using WireMock.Matchers;
using WireMock.Models;
using Xunit;
namespace WireMock.Net.Tests.Matchers;
public class FormUrlEncodedMatcherTest
{
[Theory]
[InlineData("*=*")]
[InlineData("name=John Doe")]
[InlineData("name=*")]
[InlineData("*=John Doe")]
[InlineData("email=johndoe@example.com")]
[InlineData("email=*")]
[InlineData("*=johndoe@example.com")]
[InlineData("name=John Doe", "email=johndoe@example.com")]
[InlineData("name=John Doe", "email=*")]
[InlineData("name=*", "email=*")]
[InlineData("*=John Doe", "*=johndoe@example.com")]
public async Task FormUrlEncodedMatcher_IsMatch(params string[] patterns)
{
// Arrange
var content = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("name", "John Doe"),
new KeyValuePair<string, string>("email", "johndoe@example.com")
});
var contentAsString = await content.ReadAsStringAsync();
var matcher = new FormUrlEncodedMatcher(patterns.Select(p => new AnyOf<string, StringPattern>(p)).ToArray());
// Act
var score = matcher.IsMatch(contentAsString).IsPerfect();
// Assert
score.Should().BeTrue();
}
[Theory]
[InlineData(false, "name=John Doe")]
[InlineData(false, "name=*")]
[InlineData(false, "*=John Doe")]
[InlineData(false, "email=johndoe@example.com")]
[InlineData(false, "email=*")]
[InlineData(false, "*=johndoe@example.com")]
[InlineData(true, "name=John Doe", "email=johndoe@example.com")]
[InlineData(true, "name=John Doe", "email=*")]
[InlineData(true, "name=*", "email=*")]
[InlineData(true, "*=John Doe", "*=johndoe@example.com")]
[InlineData(true, "*=*")]
public async Task FormUrlEncodedMatcher_IsMatch_And(bool expected, params string[] patterns)
{
// Arrange
var content = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("name", "John Doe"),
new KeyValuePair<string, string>("email", "johndoe@example.com")
});
var contentAsString = await content.ReadAsStringAsync();
var matcher = new FormUrlEncodedMatcher(patterns.Select(p => new AnyOf<string, StringPattern>(p)).ToArray(), true, MatchOperator.And);
// Act
var score = matcher.IsMatch(contentAsString).IsPerfect();
// Assert
score.Should().Be(expected);
}
}

View File

@@ -225,5 +225,63 @@ public partial class WireMockServerTests
server.Stop();
}
[Fact]
public async Task WireMockServer_WithBodyAsFormUrlEncoded_Using_PostAsync_And_WithFormUrlEncodedMatcher()
{
// Arrange
var matcher = new FormUrlEncodedMatcher(["email=johndoe@example.com", "name=John Doe"]);
var server = WireMockServer.Start();
server.Given(
Request.Create()
.UsingPost()
.WithPath("/foo")
.WithHeader("Content-Type", "application/x-www-form-urlencoded")
.WithBody(matcher)
)
.RespondWith(
Response.Create()
);
server.Given(
Request.Create()
.UsingPost()
.WithPath("/bar")
.WithHeader("Content-Type", "application/x-www-form-urlencoded")
.WithBody(matcher)
)
.RespondWith(
Response.Create()
);
// Act 1
var contentOrdered = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("name", "John Doe"),
new KeyValuePair<string, string>("email", "johndoe@example.com")
});
var responseOrdered = await new HttpClient()
.PostAsync($"{server.Url}/foo", contentOrdered)
.ConfigureAwait(false);
// Assert 1
responseOrdered.StatusCode.Should().Be(HttpStatusCode.OK);
// Act 2
var contentUnordered = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("email", "johndoe@example.com"),
new KeyValuePair<string, string>("name", "John Doe"),
});
var responseUnordered = await new HttpClient()
.PostAsync($"{server.Url}/bar", contentUnordered)
.ConfigureAwait(false);
// Assert 2
responseUnordered.StatusCode.Should().Be(HttpStatusCode.OK);
server.Stop();
}
}
#endif