From 6b393ebc1d630bb3ad9e72697761215b6e13b8a5 Mon Sep 17 00:00:00 2001 From: Levan Nozadze Date: Mon, 17 Jan 2022 19:04:37 +0400 Subject: [PATCH] Added support of custom matchers in static mappings (#713) * Added support of custom matchers in static mappings * Fixed code style issues * Fixed naming and code style * added empty line * Ignore serialization of CustomMatcherMappings property in WireMockServerSettings * Added integration tests for CustomMatcherMappings --- .../Serialization/MatcherMapper.cs | 5 + .../Settings/IWireMockServerSettings.cs | 9 ++ .../Settings/WireMockServerSettings.cs | 7 ++ .../Serialization/CustomPathParamMatcher.cs | 109 ++++++++++++++++++ .../CustomPathParamMatcherModel.cs | 20 ++++ .../Serialization/MatcherModelMapperTests.cs | 69 +++++++++++ .../WireMock.Net.Tests/WireMockServerTests.cs | 102 ++++++++++++++++ 7 files changed, 321 insertions(+) create mode 100644 test/WireMock.Net.Tests/Serialization/CustomPathParamMatcher.cs create mode 100644 test/WireMock.Net.Tests/Serialization/CustomPathParamMatcherModel.cs diff --git a/src/WireMock.Net/Serialization/MatcherMapper.cs b/src/WireMock.Net/Serialization/MatcherMapper.cs index ebf57430..8c6b703b 100644 --- a/src/WireMock.Net/Serialization/MatcherMapper.cs +++ b/src/WireMock.Net/Serialization/MatcherMapper.cs @@ -105,6 +105,11 @@ namespace WireMock.Serialization return new SimMetricsMatcher(matchBehaviour, stringPatterns, type, throwExceptionWhenMatcherFails); default: + if (_settings.CustomMatcherMappings != null && _settings.CustomMatcherMappings.ContainsKey(matcherName)) + { + return _settings.CustomMatcherMappings[matcherName](matcher); + } + throw new NotSupportedException($"Matcher '{matcherName}' is not supported."); } } diff --git a/src/WireMock.Net/Settings/IWireMockServerSettings.cs b/src/WireMock.Net/Settings/IWireMockServerSettings.cs index 610496e3..43b66d51 100644 --- a/src/WireMock.Net/Settings/IWireMockServerSettings.cs +++ b/src/WireMock.Net/Settings/IWireMockServerSettings.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Generic; using System.Text.RegularExpressions; using HandlebarsDotNet; using JetBrains.Annotations; +using Newtonsoft.Json; +using WireMock.Admin.Mappings; using WireMock.Handlers; using WireMock.Logging; using WireMock.Matchers; @@ -229,5 +232,11 @@ namespace WireMock.Settings /// [PublicAPI] bool? SaveUnmatchedRequests { get; set; } + + /// + /// Custom matcher mappings for static mappings + /// + [PublicAPI, JsonIgnore] + IDictionary> CustomMatcherMappings { get; set; } } } \ No newline at end of file diff --git a/src/WireMock.Net/Settings/WireMockServerSettings.cs b/src/WireMock.Net/Settings/WireMockServerSettings.cs index ab55eb30..6bb1fa28 100644 --- a/src/WireMock.Net/Settings/WireMockServerSettings.cs +++ b/src/WireMock.Net/Settings/WireMockServerSettings.cs @@ -1,9 +1,12 @@ using System; +using System.Collections.Generic; using HandlebarsDotNet; using JetBrains.Annotations; using Newtonsoft.Json; +using WireMock.Admin.Mappings; using WireMock.Handlers; using WireMock.Logging; +using WireMock.Matchers; #if USE_ASPNETCORE using Microsoft.Extensions.DependencyInjection; #endif @@ -158,5 +161,9 @@ namespace WireMock.Settings /// [PublicAPI] public bool? SaveUnmatchedRequests { get; set; } + + /// + [PublicAPI, JsonIgnore] + public IDictionary> CustomMatcherMappings { get; set; } } } \ No newline at end of file diff --git a/test/WireMock.Net.Tests/Serialization/CustomPathParamMatcher.cs b/test/WireMock.Net.Tests/Serialization/CustomPathParamMatcher.cs new file mode 100644 index 00000000..27c3b7ae --- /dev/null +++ b/test/WireMock.Net.Tests/Serialization/CustomPathParamMatcher.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using AnyOfTypes; +using Newtonsoft.Json; +using WireMock.Matchers; +using WireMock.Models; + +namespace WireMock.Net.Tests.Serialization +{ + /// + /// This matcher is only for unit test purposes + /// + public class CustomPathParamMatcher : IStringMatcher + { + public string Name => nameof(CustomPathParamMatcher); + public MatchBehaviour MatchBehaviour { get; } + public bool ThrowException { get; } + + private readonly string _path; + private readonly string[] _pathParts; + private readonly Dictionary _pathParams; + + public CustomPathParamMatcher(string path, Dictionary pathParams) : this(MatchBehaviour.AcceptOnMatch, path, pathParams) + { + } + + public CustomPathParamMatcher(MatchBehaviour matchBehaviour, string path, Dictionary pathParams, bool throwException = false) + { + MatchBehaviour = matchBehaviour; + ThrowException = throwException; + _path = path; + _pathParts = GetPathParts(path); + _pathParams = pathParams.ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase); + } + + public double IsMatch(string input) + { + var inputParts = GetPathParts(input); + if (inputParts.Length != _pathParts.Length) + { + return MatchScores.Mismatch; + } + + try + { + for (int i = 0; i < inputParts.Length; i++) + { + var inputPart = inputParts[i]; + var pathPart = _pathParts[i]; + if (pathPart.StartsWith("{") && pathPart.EndsWith("}")) + { + var pathParamName = pathPart.Trim('{').Trim('}'); + if (!_pathParams.ContainsKey(pathParamName)) + { + return MatchScores.Mismatch; + } + + if (!Regex.IsMatch(inputPart, _pathParams[pathParamName], RegexOptions.IgnoreCase)) + { + return MatchScores.Mismatch; + } + } + else + { + if (!inputPart.Equals(pathPart, StringComparison.InvariantCultureIgnoreCase)) + { + return MatchScores.Mismatch; + } + } + } + } + catch + { + if (ThrowException) + { + throw; + } + + return MatchScores.Mismatch; + } + + return MatchScores.Perfect; + } + + public AnyOf[] GetPatterns() + { + return new[] { new AnyOf(JsonConvert.SerializeObject(new CustomPathParamMatcherModel(_path, _pathParams))) }; + } + + private string[] GetPathParts(string path) + { + var hashMarkIndex = path.IndexOf('#'); + if (hashMarkIndex != -1) + { + path = path.Substring(0, hashMarkIndex); + } + + var queryParamsIndex = path.IndexOf('?'); + if (queryParamsIndex != -1) + { + path = path.Substring(0, queryParamsIndex); + } + + return path.Trim().Trim('/').ToLower().Split('/'); + } + } +} diff --git a/test/WireMock.Net.Tests/Serialization/CustomPathParamMatcherModel.cs b/test/WireMock.Net.Tests/Serialization/CustomPathParamMatcherModel.cs new file mode 100644 index 00000000..253d4dcf --- /dev/null +++ b/test/WireMock.Net.Tests/Serialization/CustomPathParamMatcherModel.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace WireMock.Net.Tests.Serialization +{ + public class CustomPathParamMatcherModel + { + public string Path { get; set; } + public Dictionary PathParams { get; set; } + + public CustomPathParamMatcherModel() + { + } + + public CustomPathParamMatcherModel(string path, Dictionary pathParams) + { + Path = path; + PathParams = pathParams; + } + } +} \ No newline at end of file diff --git a/test/WireMock.Net.Tests/Serialization/MatcherModelMapperTests.cs b/test/WireMock.Net.Tests/Serialization/MatcherModelMapperTests.cs index cef96704..3746d782 100644 --- a/test/WireMock.Net.Tests/Serialization/MatcherModelMapperTests.cs +++ b/test/WireMock.Net.Tests/Serialization/MatcherModelMapperTests.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Generic; using AnyOfTypes; using FluentAssertions; +using FluentAssertions.Execution; using Moq; +using Newtonsoft.Json; using NFluent; using WireMock.Admin.Mappings; using WireMock.Handlers; @@ -310,5 +313,71 @@ namespace WireMock.Net.Tests.Serialization // Act Check.ThatCode(() => _sut.Map(model)).Throws(); } + + [Fact] + public void MatcherModelMapper_Map_MatcherModelToCustomMatcher() + { + // Arrange + var patternModel = new CustomPathParamMatcherModel("/customer/{customerId}/document/{documentId}", + new Dictionary(2) + { + { "customerId", @"^[0-9]+$" }, + { "documentId", @"^[0-9a-zA-Z\-\_]+\.[a-zA-Z]+$" } + }); + var model = new MatcherModel + { + Name = nameof(CustomPathParamMatcher), + Pattern = JsonConvert.SerializeObject(patternModel) + }; + + var settings = new WireMockServerSettings(); + settings.CustomMatcherMappings = settings.CustomMatcherMappings ?? new Dictionary>(); + settings.CustomMatcherMappings[nameof(CustomPathParamMatcher)] = matcherModel => + { + var matcherParams = JsonConvert.DeserializeObject((string)matcherModel.Pattern); + return new CustomPathParamMatcher( + matcherModel.RejectOnMatch == true ? MatchBehaviour.RejectOnMatch : MatchBehaviour.AcceptOnMatch, + matcherParams.Path, matcherParams.PathParams, + settings.ThrowExceptionWhenMatcherFails == true + ); + }; + var sut = new MatcherMapper(settings); + + // Act + var matcher = sut.Map(model) as CustomPathParamMatcher; + + // Assert + matcher.Should().NotBeNull(); + } + + [Fact] + public void MatcherModelMapper_Map_CustomMatcherToMatcherModel() + { + // Arrange + var matcher = new CustomPathParamMatcher("/customer/{customerId}/document/{documentId}", + new Dictionary(2) + { + { "customerId", @"^[0-9]+$" }, + { "documentId", @"^[0-9a-zA-Z\-\_]+\.[a-zA-Z]+$" } + }); + + // Act + var model = _sut.Map(matcher); + + // Assert + using (new AssertionScope()) + { + model.Should().NotBeNull(); + model.Name.Should().Be(nameof(CustomPathParamMatcher)); + + var matcherParams = JsonConvert.DeserializeObject((string)model.Pattern); + matcherParams.Path.Should().Be("/customer/{customerId}/document/{documentId}"); + matcherParams.PathParams.Should().BeEquivalentTo(new Dictionary(2) + { + { "customerId", @"^[0-9]+$" }, + { "documentId", @"^[0-9a-zA-Z\-\_]+\.[a-zA-Z]+$" } + }); + } + } } } \ No newline at end of file diff --git a/test/WireMock.Net.Tests/WireMockServerTests.cs b/test/WireMock.Net.Tests/WireMockServerTests.cs index 7fc83479..380dd6c8 100644 --- a/test/WireMock.Net.Tests/WireMockServerTests.cs +++ b/test/WireMock.Net.Tests/WireMockServerTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net; @@ -7,10 +8,15 @@ using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; using FluentAssertions; +using Newtonsoft.Json; using NFluent; +using WireMock.Admin.Mappings; +using WireMock.Matchers; +using WireMock.Net.Tests.Serialization; using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using WireMock.Settings; using WireMock.Util; using Xunit; @@ -383,6 +389,102 @@ namespace WireMock.Net.Tests server.Stop(); } + + [Fact] + public async Task WireMockServer_Using_JsonMapping_And_CustomMatcher_WithCorrectParams_ShouldMatch() + { + // Arrange + var settings = new WireMockServerSettings(); + settings.WatchStaticMappings = true; + settings.WatchStaticMappingsInSubdirectories = true; + settings.CustomMatcherMappings = new Dictionary>(); + settings.CustomMatcherMappings[nameof(CustomPathParamMatcher)] = matcherModel => + { + var matcherParams = JsonConvert.DeserializeObject((string)matcherModel.Pattern); + return new CustomPathParamMatcher( + matcherModel.RejectOnMatch == true ? MatchBehaviour.RejectOnMatch : MatchBehaviour.AcceptOnMatch, + matcherParams.Path, matcherParams.PathParams, + settings.ThrowExceptionWhenMatcherFails == true + ); + }; + + var server = WireMockServer.Start(settings); + server.WithMapping(@"{ + ""Request"": { + ""Path"": { + ""Matchers"": [ + { + ""Name"": ""CustomPathParamMatcher"", + ""Pattern"": ""{\""path\"":\""/customer/{customerId}/document/{documentId}\"",\""pathParams\"":{\""customerId\"":\""^[0-9]+$\"",\""documentId\"":\""^[0-9a-zA-Z\\\\-_]+\\\\.[a-zA-Z]+$\""}}"" + } + ] + } + }, + ""Response"": { + ""StatusCode"": 200, + ""Headers"": { + ""Content-Type"": ""application/json"" + }, + ""Body"": ""OK"" + } +}"); + + // Act + var response = await new HttpClient().PostAsync("http://localhost:" + server.Ports[0] + "/customer/132/document/pic.jpg", new StringContent("{ Hi = \"Hello World\" }")).ConfigureAwait(false); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + server.Stop(); + } + + [Fact] + public async Task WireMockServer_Using_JsonMapping_And_CustomMatcher_WithIncorrectParams_ShouldNotMatch() + { + // Arrange + var settings = new WireMockServerSettings(); + settings.WatchStaticMappings = true; + settings.WatchStaticMappingsInSubdirectories = true; + settings.CustomMatcherMappings = new Dictionary>(); + settings.CustomMatcherMappings[nameof(CustomPathParamMatcher)] = matcherModel => + { + var matcherParams = JsonConvert.DeserializeObject((string)matcherModel.Pattern); + return new CustomPathParamMatcher( + matcherModel.RejectOnMatch == true ? MatchBehaviour.RejectOnMatch : MatchBehaviour.AcceptOnMatch, + matcherParams.Path, matcherParams.PathParams, + settings.ThrowExceptionWhenMatcherFails == true + ); + }; + + var server = WireMockServer.Start(settings); + server.WithMapping(@"{ + ""Request"": { + ""Path"": { + ""Matchers"": [ + { + ""Name"": ""CustomPathParamMatcher"", + ""Pattern"": ""{\""path\"":\""/customer/{customerId}/document/{documentId}\"",\""pathParams\"":{\""customerId\"":\""^[0-9]+$\"",\""documentId\"":\""^[0-9a-zA-Z\\\\-_]+\\\\.[a-zA-Z]+$\""}}"" + } + ] + } + }, + ""Response"": { + ""StatusCode"": 200, + ""Headers"": { + ""Content-Type"": ""application/json"" + }, + ""Body"": ""OK"" + } +}"); + + // Act + var response = await new HttpClient().PostAsync("http://localhost:" + server.Ports[0] + "/customer/132/document/pic", new StringContent("{ Hi = \"Hello World\" }")).ConfigureAwait(false); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + server.Stop(); + } #endif } } \ No newline at end of file