diff --git a/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs b/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs index aa249aba90..026ab4513e 100644 --- a/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs +++ b/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs @@ -530,8 +530,9 @@ namespace Microsoft.AspNetCore.Routing.Matching public sealed partial class CandidateSet { public CandidateSet(Microsoft.AspNetCore.Http.Endpoint[] endpoints, Microsoft.AspNetCore.Routing.RouteValueDictionary[] values, int[] scores) { } - public int Count { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } + public int Count { get { throw null; } } public ref Microsoft.AspNetCore.Routing.Matching.CandidateState this[int index] { [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]get { throw null; } } + public void ExpandEndpoint(int index, System.Collections.Generic.IReadOnlyList endpoints, System.Collections.Generic.IComparer comparer) { } public bool IsValidCandidate(int index) { throw null; } public void ReplaceEndpoint(int index, Microsoft.AspNetCore.Http.Endpoint endpoint, Microsoft.AspNetCore.Routing.RouteValueDictionary values) { } public void SetValidity(int index, bool value) { } @@ -545,6 +546,11 @@ namespace Microsoft.AspNetCore.Routing.Matching public int Score { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } public Microsoft.AspNetCore.Routing.RouteValueDictionary Values { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } } + public sealed partial class EndpointMetadataComparer : System.Collections.Generic.IComparer + { + internal EndpointMetadataComparer() { } + int System.Collections.Generic.IComparer.Compare(Microsoft.AspNetCore.Http.Endpoint x, Microsoft.AspNetCore.Http.Endpoint y) { throw null; } + } public abstract partial class EndpointMetadataComparer : System.Collections.Generic.IComparer where TMetadata : class { public static readonly Microsoft.AspNetCore.Routing.Matching.EndpointMetadataComparer Default; diff --git a/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs b/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs index 8a9d10a45f..23e080867b 100644 --- a/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs +++ b/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs @@ -75,6 +75,11 @@ namespace Microsoft.Extensions.DependencyInjection services.TryAddTransient(); services.TryAddSingleton(); services.TryAddTransient(); + services.TryAddSingleton(services => + { + // This has no public constructor. + return new EndpointMetadataComparer(services); + }); // Link generation related services services.TryAddSingleton(); diff --git a/src/Http/Routing/src/Matching/CandidateSet.cs b/src/Http/Routing/src/Matching/CandidateSet.cs index 6f4ffb2197..7720634e62 100644 --- a/src/Http/Routing/src/Matching/CandidateSet.cs +++ b/src/Http/Routing/src/Matching/CandidateSet.cs @@ -2,8 +2,12 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Buffers; using System.Collections; +using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Http; @@ -18,7 +22,7 @@ namespace Microsoft.AspNetCore.Routing.Matching { private const int BitVectorSize = 32; - private readonly CandidateState[] _candidates; + private CandidateState[] _candidates; /// /// @@ -55,8 +59,6 @@ namespace Microsoft.AspNetCore.Routing.Matching throw new ArgumentException($"The provided {nameof(endpoints)}, {nameof(values)}, and {nameof(scores)} must have the same length."); } - Count = endpoints.Length; - _candidates = new CandidateState[endpoints.Length]; for (var i = 0; i < endpoints.Length; i++) { @@ -66,8 +68,6 @@ namespace Microsoft.AspNetCore.Routing.Matching internal CandidateSet(Candidate[] candidates) { - Count = candidates.Length; - _candidates = new CandidateState[candidates.Length]; for (var i = 0; i < candidates.Length; i++) { @@ -78,7 +78,7 @@ namespace Microsoft.AspNetCore.Routing.Matching /// /// Gets the count of candidates in the set. /// - public int Count { get; } + public int Count => _candidates.Length; /// /// Gets the associated with the candidate @@ -153,7 +153,7 @@ namespace Microsoft.AspNetCore.Routing.Matching /// The candidate index. /// /// The to replace the original at - /// the . If the candidate will be marked + /// the . If is null. the candidate will be marked /// as invalid. /// /// @@ -167,7 +167,7 @@ namespace Microsoft.AspNetCore.Routing.Matching { ThrowIndexArgumentOutOfRangeException(); } - + _candidates[index] = new CandidateState(endpoint, values, _candidates[index].Score); if (endpoint == null) @@ -176,9 +176,188 @@ namespace Microsoft.AspNetCore.Routing.Matching } } + /// + /// Replaces the at the provided with the + /// provided . + /// + /// The candidate index. + /// + /// The list of endpoints to replace the original at + /// the . If is empty, the candidate will be marked + /// as invalid. + /// + /// + /// The endpoint comparer used to order the endpoints. Can be retrieved from the service provider as + /// type . + /// + /// + /// + /// This method supports replacing a dynamic endpoint with a collection of endpoints, and relying on + /// implementations to disambiguate further. + /// + /// + /// The endpoint being replace should have a unique score value. The score is the combination of route + /// patter precedence, order, and policy metadata evaluation. A dynamic endpoint will not function + /// correctly if other endpoints exist with the same score. + /// + /// + public void ExpandEndpoint(int index, IReadOnlyList endpoints, IComparer comparer) + { + // Friendliness for inlining + if ((uint)index >= Count) + { + ThrowIndexArgumentOutOfRangeException(); + } + + if (endpoints == null) + { + ThrowArgumentNullException(nameof(endpoints)); + } + + if (comparer == null) + { + ThrowArgumentNullException(nameof(comparer)); + } + + // First we need to verify that the score of what we're replacing is unique. + ValidateUniqueScore(index); + + switch (endpoints.Count) + { + case 0: + ReplaceEndpoint(index, null, null); + break; + + case 1: + ReplaceEndpoint(index, endpoints[0], _candidates[index].Values); + break; + + default: + + var score = GetOriginalScore(index); + var values = _candidates[index].Values; + + // Adding candidates requires expanding the array and computing new score values for the new candidates. + var original = _candidates; + var candidates = new CandidateState[original.Length - 1 + endpoints.Count]; + _candidates = candidates; + + // Since the new endpoints have an unknown ordering relationship to each other, we need to: + // - order them + // - assign scores + // - offset everything that comes after + // + // If the inputs look like: + // + // score 0: A1 + // score 0: A2 + // score 1: B + // score 2: C <-- being expanded + // score 3: D + // + // Then the result should look like: + // + // score 0: A1 + // score 0: A2 + // score 1: B + // score 2: `C1 + // score 3: `C2 + // score 4: D + + // Candidates before index can be copied unchanged. + for (var i = 0; i < index; i++) + { + candidates[i] = original[i]; + } + + var buffer = endpoints.ToArray(); + Array.Sort(buffer, comparer); + + // Add the first new endpoint with the current score + candidates[index] = new CandidateState(buffer[0], values, score); + + var scoreOffset = 0; + for (var i = 1; i < buffer.Length; i++) + { + var cmp = comparer.Compare(buffer[i - 1], buffer[i]); + + // This should not be possible. This would mean that sorting is wrong. + Debug.Assert(cmp <= 0); + if (cmp == 0) + { + // Score is unchanged. + } + else if (cmp < 0) + { + // Endpoint is lower priority, higher score. + scoreOffset++; + } + + _candidates[i + index] = new CandidateState(buffer[i], values, score + scoreOffset); + } + + for (var i = index + 1; i < original.Length; i++) + { + _candidates[i + endpoints.Count - 1] = new CandidateState(original[i].Endpoint, original[i].Values, original[i].Score + scoreOffset); + } + + break; + + } + } + + // Returns the *positive* score value. Score is used to track valid/invalid which can cause it to be negative. + // + // This is the original score and used to determine if there are ambiguities. + private int GetOriginalScore(int index) + { + var score = _candidates[index].Score; + return score >= 0 ? score : ~score; + } + + private void ValidateUniqueScore(int index) + { + var score = GetOriginalScore(index); + + var count = 0; + var candidates = _candidates; + for (var i = 0; i < candidates.Length; i++) + { + if (GetOriginalScore(i) == score) + { + count++; + } + } + + Debug.Assert(count > 0); + if (count > 1) + { + // Uh-oh. We don't allow duplicates with ExpandEndpoint because that will do unpredictable things. + var duplicates = new List(); + for (var i = 0; i < candidates.Length; i++) + { + if (GetOriginalScore(i) == score) + { + duplicates.Add(candidates[i].Endpoint); + } + } + + var message = + $"Using {nameof(ExpandEndpoint)} requires that the replaced endpoint have a unique priority. " + + $"The following endpoints were found with the same priority:" + Environment.NewLine + + string.Join(Environment.NewLine, duplicates.Select(e => e.DisplayName)); + throw new InvalidOperationException(message); + } + } + private static void ThrowIndexArgumentOutOfRangeException() { throw new ArgumentOutOfRangeException("index"); } + + private static void ThrowArgumentNullException(string parameter) + { + throw new ArgumentNullException(parameter); + } } } diff --git a/src/Http/Routing/src/Matching/EndpointMetadataComparer.cs b/src/Http/Routing/src/Matching/EndpointMetadataComparer.cs index 721d1816dd..cb42890552 100644 --- a/src/Http/Routing/src/Matching/EndpointMetadataComparer.cs +++ b/src/Http/Routing/src/Matching/EndpointMetadataComparer.cs @@ -1,12 +1,79 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Collections.Generic; +using System.Linq; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Routing.Matching { + /// + /// A comparer that can order instances based on implementations of + /// . The implementation can be retrieved from the service + /// provider and provided to . + /// + public sealed class EndpointMetadataComparer : IComparer + { + private IServiceProvider _services; + private IComparer[] _comparers; + + // This type is **INTENDED** for use in MatcherPolicy instances yet is also needs the MatcherPolicy instances. + // using IServiceProvider to break the cycle. + internal EndpointMetadataComparer(IServiceProvider services) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + _services = services; + } + + private IComparer[] Comparers + { + get + { + if (_comparers == null) + { + _comparers = _services.GetServices() + .OrderBy(p => p.Order) + .OfType() + .Select(p => p.Comparer) + .ToArray(); + } + + return _comparers; + } + } + + int IComparer.Compare(Endpoint x, Endpoint y) + { + if (x == null) + { + throw new ArgumentNullException(nameof(x)); + } + + if (y == null) + { + throw new ArgumentNullException(nameof(y)); + } + + var comparers = Comparers; + for (var i = 0; i < comparers.Length; i++) + { + var compare = comparers[i].Compare(x, y); + if (compare != 0) + { + return compare; + } + } + + return 0; + } + } + /// /// A base class for implementations that use /// a specific type of metadata from for comparison. diff --git a/src/Http/Routing/test/UnitTests/Matching/CandidateSetTest.cs b/src/Http/Routing/test/UnitTests/Matching/CandidateSetTest.cs index 1254e24031..dd14f7caeb 100644 --- a/src/Http/Routing/test/UnitTests/Matching/CandidateSetTest.cs +++ b/src/Http/Routing/test/UnitTests/Matching/CandidateSetTest.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing.Patterns; @@ -13,22 +14,11 @@ namespace Microsoft.AspNetCore.Routing.Matching { public class CandidateSetTest { - // We special case low numbers of candidates, so we want to verify that it works correctly for a variety - // of input sizes. - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - [InlineData(3)] - [InlineData(4)] - [InlineData(5)] // this is the break-point where we start to use a list. - [InlineData(6)] - [InlineData(31)] - [InlineData(32)] // this is the break point where we use a BitArray - [InlineData(33)] - public void Create_CreatesCandidateSet(int count) + [Fact] + public void Create_CreatesCandidateSet() { // Arrange + var count = 10; var endpoints = new RouteEndpoint[count]; for (var i = 0; i < endpoints.Length; i++) { @@ -55,22 +45,11 @@ namespace Microsoft.AspNetCore.Routing.Matching } } - // We special case low numbers of candidates, so we want to verify that it works correctly for a variety - // of input sizes. - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - [InlineData(3)] - [InlineData(4)] - [InlineData(5)] // this is the break-point where we start to use a list. - [InlineData(6)] - [InlineData(31)] - [InlineData(32)] // this is the break point where we use a BitArray - [InlineData(33)] - public void ReplaceEndpoint_WithEndpoint(int count) + [Fact] + public void ReplaceEndpoint_WithEndpoint() { // Arrange + var count = 10; var endpoints = new RouteEndpoint[count]; for (var i = 0; i < endpoints.Length; i++) { @@ -99,22 +78,11 @@ namespace Microsoft.AspNetCore.Routing.Matching } } - // We special case low numbers of candidates, so we want to verify that it works correctly for a variety - // of input sizes. - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - [InlineData(3)] - [InlineData(4)] - [InlineData(5)] // this is the break-point where we start to use a list. - [InlineData(6)] - [InlineData(31)] - [InlineData(32)] // this is the break point where we use a BitArray - [InlineData(33)] - public void ReplaceEndpoint_WithEndpoint_Null(int count) + [Fact] + public void ReplaceEndpoint_WithEndpoint_Null() { // Arrange + var count = 10; var endpoints = new RouteEndpoint[count]; for (var i = 0; i < endpoints.Length; i++) { @@ -131,7 +99,7 @@ namespace Microsoft.AspNetCore.Routing.Matching ref var state = ref candidateSet[i]; // Act - candidateSet.ReplaceEndpoint(i, null, null); + candidateSet.ReplaceEndpoint(i, (Endpoint)null, null); // Assert Assert.Null(state.Endpoint); @@ -140,22 +108,235 @@ namespace Microsoft.AspNetCore.Routing.Matching } } - // We special case low numbers of candidates, so we want to verify that it works correctly for a variety - // of input sizes. - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - [InlineData(3)] - [InlineData(4)] - [InlineData(5)] // this is the break-point where we start to use a list. - [InlineData(6)] - [InlineData(31)] - [InlineData(32)] // this is the break point where we use a BitArray - [InlineData(33)] - public void Create_CreatesCandidateSet_TestConstructor(int count) + [Fact] + public void ExpandEndpoint_EmptyList() { // Arrange + var count = 10; + var endpoints = new RouteEndpoint[count]; + for (var i = 0; i < endpoints.Length; i++) + { + endpoints[i] = CreateEndpoint($"/{i}", order: i); + } + + var builder = CreateDfaMatcherBuilder(); + var candidates = builder.CreateCandidates(endpoints); + + var candidateSet = new CandidateSet(candidates); + + var services = new Mock(); + services.Setup(s => s.GetService(typeof(IEnumerable))).Returns(new[] { new TestMetadataMatcherPolicy(), }); + var comparer = new EndpointMetadataComparer(services.Object); + + // Act + candidateSet.ExpandEndpoint(0, Array.Empty(), comparer); + + // Assert + + Assert.Null(candidateSet[0].Endpoint); + Assert.False(candidateSet.IsValidCandidate(0)); + + for (var i = 1; i < candidateSet.Count; i++) + { + ref var state = ref candidateSet[i]; + + Assert.Same(endpoints[i], state.Endpoint); + } + } + + [Fact] + public void ExpandEndpoint_Beginning() + { + // Arrange + var count = 10; + var endpoints = new RouteEndpoint[count]; + for (var i = 0; i < endpoints.Length; i++) + { + endpoints[i] = CreateEndpoint($"/{i}", order: i); + } + + var builder = CreateDfaMatcherBuilder(); + var candidates = builder.CreateCandidates(endpoints); + + var candidateSet = new CandidateSet(candidates); + + var replacements = new RouteEndpoint[3] + { + CreateEndpoint($"new /A", metadata: new object[]{ new TestMetadata(), }), + CreateEndpoint($"new /B", metadata: new object[]{ }), + CreateEndpoint($"new /C", metadata: new object[]{ new TestMetadata(), }), + }; + + var services = new Mock(); + services.Setup(s => s.GetService(typeof(IEnumerable))).Returns(new[] { new TestMetadataMatcherPolicy(), }); + var comparer = new EndpointMetadataComparer(services.Object); + + candidateSet.SetValidity(0, false); // Has no effect. We always count new stuff as valid by default. + + // Act + candidateSet.ExpandEndpoint(0, replacements, comparer); + + // Assert + Assert.Equal(12, candidateSet.Count); + + Assert.Same(replacements[0], candidateSet[0].Endpoint); + Assert.Equal(0, candidateSet[0].Score); + Assert.Same(replacements[2], candidateSet[1].Endpoint); + Assert.Equal(0, candidateSet[1].Score); + Assert.Same(replacements[1], candidateSet[2].Endpoint); + Assert.Equal(1, candidateSet[2].Score); + + for (var i = 3; i < candidateSet.Count; i++) + { + ref var state = ref candidateSet[i]; + Assert.Same(endpoints[i - 2], state.Endpoint); + Assert.Equal(i - 1, candidateSet[i].Score); + } + } + + [Fact] + public void ExpandEndpoint_Middle() + { + // Arrange + var count = 10; + var endpoints = new RouteEndpoint[count]; + for (var i = 0; i < endpoints.Length; i++) + { + endpoints[i] = CreateEndpoint($"/{i}", order: i); + } + + var builder = CreateDfaMatcherBuilder(); + var candidates = builder.CreateCandidates(endpoints); + + var candidateSet = new CandidateSet(candidates); + + var replacements = new RouteEndpoint[3] + { + CreateEndpoint($"new /A", metadata: new object[]{ new TestMetadata(), }), + CreateEndpoint($"new /B", metadata: new object[]{ }), + CreateEndpoint($"new /C", metadata: new object[]{ new TestMetadata(), }), + }; + + var services = new Mock(); + services.Setup(s => s.GetService(typeof(IEnumerable))).Returns(new[] { new TestMetadataMatcherPolicy(), }); + var comparer = new EndpointMetadataComparer(services.Object); + + candidateSet.SetValidity(5, false); // Has no effect. We always count new stuff as valid by default. + + // Act + candidateSet.ExpandEndpoint(5, replacements, comparer); + + // Assert + Assert.Equal(12, candidateSet.Count); + + for (var i = 0; i < 5; i++) + { + ref var state = ref candidateSet[i]; + Assert.Same(endpoints[i], state.Endpoint); + Assert.Equal(i, candidateSet[i].Score); + } + + Assert.Same(replacements[0], candidateSet[5].Endpoint); + Assert.Equal(5, candidateSet[5].Score); + Assert.Same(replacements[2], candidateSet[6].Endpoint); + Assert.Equal(5, candidateSet[6].Score); + Assert.Same(replacements[1], candidateSet[7].Endpoint); + Assert.Equal(6, candidateSet[7].Score); + + for (var i = 8; i < candidateSet.Count; i++) + { + ref var state = ref candidateSet[i]; + Assert.Same(endpoints[i - 2], state.Endpoint); + Assert.Equal(i - 1, candidateSet[i].Score); + } + } + + [Fact] + public void ExpandEndpoint_End() + { + // Arrange + var count = 10; + var endpoints = new RouteEndpoint[count]; + for (var i = 0; i < endpoints.Length; i++) + { + endpoints[i] = CreateEndpoint($"/{i}", order: i); + } + + var builder = CreateDfaMatcherBuilder(); + var candidates = builder.CreateCandidates(endpoints); + + var candidateSet = new CandidateSet(candidates); + + var replacements = new RouteEndpoint[3] + { + CreateEndpoint($"new /A", metadata: new object[]{ new TestMetadata(), }), + CreateEndpoint($"new /B", metadata: new object[]{ }), + CreateEndpoint($"new /C", metadata: new object[]{ new TestMetadata(), }), + }; + + var services = new Mock(); + services.Setup(s => s.GetService(typeof(IEnumerable))).Returns(new[] { new TestMetadataMatcherPolicy(), }); + var comparer = new EndpointMetadataComparer(services.Object); + + candidateSet.SetValidity(9, false); // Has no effect. We always count new stuff as valid by default. + + // Act + candidateSet.ExpandEndpoint(9, replacements, comparer); + + // Assert + Assert.Equal(12, candidateSet.Count); + + for (var i = 0; i < 9; i++) + { + ref var state = ref candidateSet[i]; + Assert.Same(endpoints[i], state.Endpoint); + Assert.Equal(i, candidateSet[i].Score); + } + + Assert.Same(replacements[0], candidateSet[9].Endpoint); + Assert.Equal(9, candidateSet[9].Score); + Assert.Same(replacements[2], candidateSet[10].Endpoint); + Assert.Equal(9, candidateSet[10].Score); + Assert.Same(replacements[1], candidateSet[11].Endpoint); + Assert.Equal(10, candidateSet[11].Score); + } + + [Fact] + public void ExpandEndpoint_ThrowsForDuplicateScore() + { + // Arrange + var count = 2; + var endpoints = new RouteEndpoint[count]; + for (var i = 0; i < endpoints.Length; i++) + { + endpoints[i] = CreateEndpoint($"/{i}", order: 0); + } + + var builder = CreateDfaMatcherBuilder(); + var candidates = builder.CreateCandidates(endpoints); + + var candidateSet = new CandidateSet(candidates); + + var services = new Mock(); + services.Setup(s => s.GetService(typeof(IEnumerable))).Returns(new[] { new TestMetadataMatcherPolicy(), }); + var comparer = new EndpointMetadataComparer(services.Object); + + // Act + var ex = Assert.Throws(() => candidateSet.ExpandEndpoint(0, Array.Empty(), comparer)); + + // Assert + Assert.Equal(@" +Using ExpandEndpoint requires that the replaced endpoint have a unique priority. The following endpoints were found with the same priority: +test: /0 +test: /1" + .TrimStart(), ex.Message); + } + + [Fact] + public void Create_CreatesCandidateSet_TestConstructor() + { + // Arrange + var count = 10; var endpoints = new RouteEndpoint[count]; for (var i = 0; i < endpoints.Length; i++) { @@ -189,14 +370,16 @@ namespace Microsoft.AspNetCore.Routing.Matching } } - private RouteEndpoint CreateEndpoint(string template) + private RouteEndpoint CreateEndpoint(string template, int order = 0, params object[] metadata) { - return new RouteEndpoint( - TestConstants.EmptyRequestDelegate, - RoutePatternFactory.Parse(template), - 0, - EndpointMetadataCollection.Empty, - "test"); + var builder = new RouteEndpointBuilder(TestConstants.EmptyRequestDelegate, RoutePatternFactory.Parse(template), order); + for (var i = 0; i < metadata.Length; i++) + { + builder.Metadata.Add(metadata[i]); + } + + builder.DisplayName = "test: " + template; + return (RouteEndpoint)builder.Build(); } private static DfaMatcherBuilder CreateDfaMatcherBuilder(params MatcherPolicy[] policies) @@ -208,5 +391,15 @@ namespace Microsoft.AspNetCore.Routing.Matching Mock.Of(), policies); } + + private class TestMetadata + { + } + + private class TestMetadataMatcherPolicy : MatcherPolicy, IEndpointComparerPolicy + { + public override int Order { get; } + public IComparer Comparer => EndpointMetadataComparer.Default; + } } }