diff --git a/src/WireMock.Net.Abstractions/Admin/Mappings/MatcherModel.cs b/src/WireMock.Net.Abstractions/Admin/Mappings/MatcherModel.cs index 1e624f64..ca22db85 100644 --- a/src/WireMock.Net.Abstractions/Admin/Mappings/MatcherModel.cs +++ b/src/WireMock.Net.Abstractions/Admin/Mappings/MatcherModel.cs @@ -73,4 +73,8 @@ public class MatcherModel /// public MatcherModel? ContentMatcher { get; set; } #endregion + + #region XPathMatcher + public XmlNamespace[]? XmlNamespaceMap { get; set; } + #endregion } \ No newline at end of file diff --git a/src/WireMock.Net.Abstractions/Admin/Mappings/XmlNamespace.cs b/src/WireMock.Net.Abstractions/Admin/Mappings/XmlNamespace.cs new file mode 100644 index 00000000..ca256567 --- /dev/null +++ b/src/WireMock.Net.Abstractions/Admin/Mappings/XmlNamespace.cs @@ -0,0 +1,21 @@ +namespace WireMock.Admin.Mappings; + +/// +/// Defines an xml namespace consisting of prefix and uri. +/// xmlns:i="http://www.w3.org/2001/XMLSchema-instance" +/// +[FluentBuilder.AutoGenerateBuilder] +public class XmlNamespace +{ + /// + /// The prefix. + /// i + /// + public string Prefix { get; set; } = null!; + + /// + /// The uri. + /// http://www.w3.org/2001/XMLSchema-instance + /// + public string Uri { get; set; } = null!; +} \ No newline at end of file diff --git a/src/WireMock.Net/Matchers/XPathMatcher.cs b/src/WireMock.Net/Matchers/XPathMatcher.cs index 8bf0d7ae..ed9dbeca 100644 --- a/src/WireMock.Net/Matchers/XPathMatcher.cs +++ b/src/WireMock.Net/Matchers/XPathMatcher.cs @@ -1,5 +1,5 @@ using System; -using System.Diagnostics.CodeAnalysis; +using System.Collections.Generic; using System.Linq; using System.Xml; using System.Xml.XPath; @@ -7,6 +7,7 @@ using AnyOfTypes; using WireMock.Extensions; using WireMock.Models; using Stef.Validation; +using WireMock.Admin.Mappings; #if !NETSTANDARD1_3 using Wmhelp.XPath2; #endif @@ -24,11 +25,16 @@ public class XPathMatcher : IStringMatcher /// public MatchBehaviour MatchBehaviour { get; } + /// + /// Array of namespace prefix and uri. + /// + public XmlNamespace[]? XmlNamespaceMap { get; private set; } + /// /// Initializes a new instance of the class. /// /// The patterns. - public XPathMatcher(params AnyOf[] patterns) : this(MatchBehaviour.AcceptOnMatch, MatchOperator.Or, patterns) + public XPathMatcher(params AnyOf[] patterns) : this(MatchBehaviour.AcceptOnMatch, MatchOperator.Or, null, patterns) { } @@ -37,13 +43,16 @@ public class XPathMatcher : IStringMatcher /// /// The match behaviour. /// The to use. (default = "Or") + /// The xml namespaces of the xml document. /// The patterns. public XPathMatcher( MatchBehaviour matchBehaviour, MatchOperator matchOperator = MatchOperator.Or, + XmlNamespace[]? xmlNamespaceMap = null, params AnyOf[] patterns) { _patterns = Guard.NotNull(patterns); + XmlNamespaceMap = xmlNamespaceMap; MatchBehaviour = matchBehaviour; MatchOperator = matchOperator; } @@ -52,24 +61,34 @@ public class XPathMatcher : IStringMatcher public MatchResult IsMatch(string? input) { var score = MatchScores.Mismatch; - Exception? exception = null; - if (input != null && TryGetXPathNavigator(input, out var nav)) + if (input == null) { - try - { -#if NETSTANDARD1_3 - score = MatchScores.ToScore(_patterns.Select(p => true.Equals(nav.Evaluate($"boolean({p.GetPattern()})"))).ToArray(), MatchOperator); -#else - score = MatchScores.ToScore(_patterns.Select(p => true.Equals(nav.XPath2Evaluate($"boolean({p.GetPattern()})"))).ToArray(), MatchOperator); -#endif - } - catch (Exception ex) - { - exception = ex; - } + return CreateMatchResult(score); } + try + { + var xPathEvaluator = new XPathEvaluator(); + xPathEvaluator.Load(input); + + if (!xPathEvaluator.IsXmlDocumentLoaded) + { + return CreateMatchResult(score); + } + + score = MatchScores.ToScore(xPathEvaluator.Evaluate(_patterns, XmlNamespaceMap), MatchOperator); + } + catch (Exception exception) + { + return CreateMatchResult(score, exception); + } + + return CreateMatchResult(score); + } + + private MatchResult CreateMatchResult(double score, Exception? exception = null) + { return new MatchResult(MatchBehaviourHelper.Convert(MatchBehaviour, score), exception); } @@ -84,18 +103,54 @@ public class XPathMatcher : IStringMatcher /// public string Name => nameof(XPathMatcher); - - private static bool TryGetXPathNavigator(string input, [NotNullWhen(true)] out XPathNavigator? nav) + + private class XPathEvaluator { - try + private XmlDocument? _xmlDocument; + private XPathNavigator? _xpathNavigator; + + public bool IsXmlDocumentLoaded => _xmlDocument != null; + + public void Load(string input) { - nav = new XmlDocument { InnerXml = input }.CreateNavigator()!; - return true; + try + { + _xmlDocument = new XmlDocument { InnerXml = input }; + _xpathNavigator = _xmlDocument.CreateNavigator(); + } + catch + { + _xmlDocument = default; + } } - catch + + public bool[] Evaluate(AnyOf[] patterns, IEnumerable? xmlNamespaceMap) { - nav = default; - return false; + XmlNamespaceManager? xmlNamespaceManager = GetXmlNamespaceManager(xmlNamespaceMap); + return patterns + .Select(p => +#if NETSTANDARD1_3 + true.Equals(_xpathNavigator.Evaluate($"boolean({p.GetPattern()})", xmlNamespaceManager))) +#else + true.Equals(_xpathNavigator.XPath2Evaluate($"boolean({p.GetPattern()})", xmlNamespaceManager))) +#endif + .ToArray(); + } + + private XmlNamespaceManager? GetXmlNamespaceManager(IEnumerable? xmlNamespaceMap) + { + if (_xpathNavigator == null || xmlNamespaceMap == null) + { + return default; + } + + var nsManager = new XmlNamespaceManager(_xpathNavigator.NameTable); + foreach (XmlNamespace xmlNamespace in xmlNamespaceMap) + { + nsManager.AddNamespace(xmlNamespace.Prefix, xmlNamespace.Uri); + } + + return nsManager; } } } \ No newline at end of file diff --git a/src/WireMock.Net/Serialization/MatcherMapper.cs b/src/WireMock.Net/Serialization/MatcherMapper.cs index 4fb801b8..0ba6b168 100644 --- a/src/WireMock.Net/Serialization/MatcherMapper.cs +++ b/src/WireMock.Net/Serialization/MatcherMapper.cs @@ -101,7 +101,8 @@ internal class MatcherMapper return new JmesPathMatcher(matchBehaviour, matchOperator, stringPatterns); case nameof(XPathMatcher): - return new XPathMatcher(matchBehaviour, matchOperator, stringPatterns); + var xmlNamespaces = matcher.XmlNamespaceMap; + return new XPathMatcher(matchBehaviour, matchOperator, xmlNamespaces, stringPatterns); case nameof(WildcardMatcher): return new WildcardMatcher(matchBehaviour, stringPatterns, ignoreCase, matchOperator); @@ -159,6 +160,10 @@ internal class MatcherMapper case JsonPartialWildcardMatcher jsonPartialWildcardMatcher: model.Regex = jsonPartialWildcardMatcher.Regex; break; + + case XPathMatcher xpathMatcher: + model.XmlNamespaceMap = xpathMatcher.XmlNamespaceMap; + break; } switch (matcher) diff --git a/test/WireMock.Net.Tests/Matchers/XPathMatcherTests.cs b/test/WireMock.Net.Tests/Matchers/XPathMatcherTests.cs index 5346f349..df4d95eb 100644 --- a/test/WireMock.Net.Tests/Matchers/XPathMatcherTests.cs +++ b/test/WireMock.Net.Tests/Matchers/XPathMatcherTests.cs @@ -1,4 +1,5 @@ using NFluent; +using WireMock.Admin.Mappings; using WireMock.Matchers; using Xunit; @@ -49,6 +50,71 @@ public class XPathMatcherTests Check.That(result).IsEqualTo(1.0); } + [Fact] + public void XPathMatcher_IsMatch_WithNamespaces_AcceptOnMatch() + { + // Assign + string input = + @" + + + + + + + + "; + var xmlNamespaces = new[] + { + new XmlNamespace { Prefix = "s", Uri = "http://schemas.xmlsoap.org/soap/envelope/" }, + new XmlNamespace { Prefix = "i", Uri = "http://www.w3.org/2001/XMLSchema-instance" } + }; + var matcher = new XPathMatcher( + MatchBehaviour.AcceptOnMatch, + MatchOperator.Or, + xmlNamespaces, + "/s:Envelope/s:Body/*[local-name()='QueryRequest' and namespace-uri()='urn://MyWcfService']"); + + // Act + double result = matcher.IsMatch(input).Score; + + // Assert + Check.That(result).IsEqualTo(1.0); + } + + [Fact] + public void XPathMatcher_IsMatch_WithNamespaces_OneSelfDefined_AcceptOnMatch() + { + // Assign + string input = + @" + + + + + + + + "; + var xmlNamespaces = new[] + { + new XmlNamespace { Prefix = "s", Uri = "http://schemas.xmlsoap.org/soap/envelope/" }, + new XmlNamespace { Prefix = "i", Uri = "http://www.w3.org/2001/XMLSchema-instance" }, + new XmlNamespace { Prefix = "q", Uri = "urn://MyWcfService" } + }; + var matcher = new XPathMatcher( + MatchBehaviour.AcceptOnMatch, + MatchOperator.Or, + xmlNamespaces, + "/s:Envelope/s:Body/q:QueryRequest"); + + // Act + double result = matcher.IsMatch(input).Score; + + // Assert + Check.That(result).IsEqualTo(1.0); + } + [Fact] public void XPathMatcher_IsMatch_RejectOnMatch() { @@ -57,7 +123,7 @@ public class XPathMatcherTests abc "; - var matcher = new XPathMatcher(MatchBehaviour.RejectOnMatch, MatchOperator.Or, "/todo-list[count(todo-item) = 1]"); + var matcher = new XPathMatcher(MatchBehaviour.RejectOnMatch, MatchOperator.Or, null, "/todo-list[count(todo-item) = 1]"); // Act double result = matcher.IsMatch(xml).Score; diff --git a/test/WireMock.Net.Tests/Serialization/MatcherMapperTests.cs b/test/WireMock.Net.Tests/Serialization/MatcherMapperTests.cs index d73da131..8cc7f9e6 100644 --- a/test/WireMock.Net.Tests/Serialization/MatcherMapperTests.cs +++ b/test/WireMock.Net.Tests/Serialization/MatcherMapperTests.cs @@ -145,6 +145,26 @@ public class MatcherMapperTests model.IgnoreCase.Should().BeTrue(); } + [Fact] + public void MatcherMapper_Map_XPathMatcher() + { + // Assign + var xmlNamespaceMap = new[] + { + new XmlNamespace { Prefix = "s", Uri = "http://schemas.xmlsoap.org/soap/envelope/" }, + new XmlNamespace { Prefix = "i", Uri = "http://www.w3.org/2001/XMLSchema-instance" }, + new XmlNamespace { Prefix = "q", Uri = "urn://MyWcfService" } + }; + var matcher = new XPathMatcher(MatchBehaviour.AcceptOnMatch, MatchOperator.And, xmlNamespaceMap); + + // Act + var model = _sut.Map(matcher)!; + + // Assert + model.XmlNamespaceMap.Should().NotBeNull(); + model.XmlNamespaceMap.Should().BeEquivalentTo(xmlNamespaceMap); + } + [Fact] public void MatcherMapper_Map_MatcherModel_Null() { @@ -522,4 +542,47 @@ public class MatcherMapperTests matcher.ContentTypeMatcher.Should().BeAssignableTo().Which.GetPatterns().Should().ContainSingle("text/json"); } #endif + + [Fact] + public void MatcherMapper_Map_MatcherModel_XPathMatcher_WithXmlNamespaces_As_String() + { + // Assign + var pattern = "/s:Envelope/s:Body/*[local-name()='QueryRequest']"; + var model = new MatcherModel + { + Name = "XPathMatcher", + Pattern = pattern, + XmlNamespaceMap = new[] + { + new XmlNamespace { Prefix = "s", Uri = "http://schemas.xmlsoap.org/soap/envelope/" } + } + }; + + // Act + var matcher = (XPathMatcher)_sut.Map(model)!; + + // Assert + matcher.MatchBehaviour.Should().Be(MatchBehaviour.AcceptOnMatch); + matcher.XmlNamespaceMap.Should().NotBeNull(); + matcher.XmlNamespaceMap.Should().HaveCount(1); + } + + [Fact] + public void MatcherMapper_Map_MatcherModel_XPathMatcher_WithoutXmlNamespaces_As_String() + { + // Assign + var pattern = "/s:Envelope/s:Body/*[local-name()='QueryRequest']"; + var model = new MatcherModel + { + Name = "XPathMatcher", + Pattern = pattern + }; + + // Act + var matcher = (XPathMatcher)_sut.Map(model)!; + + // Assert + matcher.MatchBehaviour.Should().Be(MatchBehaviour.AcceptOnMatch); + matcher.XmlNamespaceMap.Should().BeNull(); + } } \ No newline at end of file