diff --git a/benchmarks/Microsoft.AspNetCore.Dispatcher.Performance/DispatcherBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Dispatcher.Performance/DispatcherBenchmark.cs index 03b3019101..56952ddb24 100644 --- a/benchmarks/Microsoft.AspNetCore.Dispatcher.Performance/DispatcherBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Dispatcher.Performance/DispatcherBenchmark.cs @@ -31,8 +31,8 @@ namespace Microsoft.AspNetCore.Dispatcher.Performance var treeBuilder = new TreeRouteBuilder( NullLoggerFactory.Instance, UrlEncoder.Default, - new DefaultObjectPool(new UriBuilderContextPooledObjectPolicy(UrlEncoder.Default)), - new DefaultInlineConstraintResolver(new OptionsManager(new OptionsFactory(Enumerable.Empty>(), Enumerable.Empty>())))); + new DefaultObjectPool(new UriBuilderContextPooledObjectPolicy()), + new DefaultInlineConstraintResolver(Options.Create(new RouteOptions()))); treeBuilder.MapInbound(handler, Routing.Template.TemplateParser.Parse("api/Widgets"), "default", 0); treeBuilder.MapInbound(handler, Routing.Template.TemplateParser.Parse("api/Widgets/{id}"), "default", 0); diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/RoutingBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/RoutingBenchmark.cs index 76cca850cf..2a9298c66f 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/RoutingBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/RoutingBenchmark.cs @@ -6,8 +6,8 @@ using System.Linq; using System.Text.Encodings.Web; using System.Threading.Tasks; using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Dispatcher; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing.Internal; using Microsoft.AspNetCore.Routing.Template; using Microsoft.AspNetCore.Routing.Tree; using Microsoft.Extensions.Logging.Abstractions; @@ -31,8 +31,8 @@ namespace Microsoft.AspNetCore.Routing.Performance var treeBuilder = new TreeRouteBuilder( NullLoggerFactory.Instance, UrlEncoder.Default, - new DefaultObjectPool(new UriBuilderContextPooledObjectPolicy(UrlEncoder.Default)), - new DefaultInlineConstraintResolver(new OptionsManager(new OptionsFactory(Enumerable.Empty>(), Enumerable.Empty>())))); + new DefaultObjectPool(new UriBuilderContextPooledObjectPolicy()), + new DefaultInlineConstraintResolver(Options.Create(new RouteOptions()))); treeBuilder.MapInbound(handler, TemplateParser.Parse("api/Widgets"), "default", 0); treeBuilder.MapInbound(handler, TemplateParser.Parse("api/Widgets/{id}"), "default", 0); diff --git a/src/Microsoft.AspNetCore.Routing/Internal/BufferValue.cs b/src/Microsoft.AspNetCore.Dispatcher/BufferValue.cs similarity index 90% rename from src/Microsoft.AspNetCore.Routing/Internal/BufferValue.cs rename to src/Microsoft.AspNetCore.Dispatcher/BufferValue.cs index 578c396b4d..40ad3b6047 100644 --- a/src/Microsoft.AspNetCore.Routing/Internal/BufferValue.cs +++ b/src/Microsoft.AspNetCore.Dispatcher/BufferValue.cs @@ -1,7 +1,7 @@ // 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. -namespace Microsoft.AspNetCore.Routing.Internal +namespace Microsoft.AspNetCore.Dispatcher { public struct BufferValue { diff --git a/src/Microsoft.AspNetCore.Dispatcher/DispatcherServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.Dispatcher/DispatcherServiceCollectionExtensions.cs index 914b6f6106..45c6c8a18b 100644 --- a/src/Microsoft.AspNetCore.Dispatcher/DispatcherServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.Dispatcher/DispatcherServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using System; using Microsoft.AspNetCore.Dispatcher; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Options; namespace Microsoft.Extensions.DependencyInjection @@ -18,7 +19,7 @@ namespace Microsoft.Extensions.DependencyInjection throw new ArgumentNullException(nameof(services)); } - // Adds the EndpointMiddleare at the end of the pipeline if the DispatcherMiddleware is in use. + // Adds the EndpointMiddleware at the end of the pipeline if the DispatcherMiddleware is in use. services.TryAddEnumerable(ServiceDescriptor.Singleton()); // Adds a default dispatcher which will collect all data sources and endpoint selectors from DI. @@ -27,6 +28,15 @@ namespace Microsoft.Extensions.DependencyInjection services.AddSingleton(); services.AddSingleton(); + // + // Infrastructure + // + services.AddSingleton>(s => + { + var provider = s.GetRequiredService(); + return provider.Create(new UriBuilderContextPooledObjectPolicy()); + }); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; diff --git a/src/Microsoft.AspNetCore.Dispatcher/Microsoft.AspNetCore.Dispatcher.csproj b/src/Microsoft.AspNetCore.Dispatcher/Microsoft.AspNetCore.Dispatcher.csproj index 5c27ac5cb0..9252251c31 100644 --- a/src/Microsoft.AspNetCore.Dispatcher/Microsoft.AspNetCore.Dispatcher.csproj +++ b/src/Microsoft.AspNetCore.Dispatcher/Microsoft.AspNetCore.Dispatcher.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Microsoft.AspNetCore.Dispatcher/RoutePatternBinder.cs b/src/Microsoft.AspNetCore.Dispatcher/RoutePatternBinder.cs new file mode 100644 index 0000000000..41f6fb8e5a --- /dev/null +++ b/src/Microsoft.AspNetCore.Dispatcher/RoutePatternBinder.cs @@ -0,0 +1,441 @@ +// 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; +using System.Diagnostics; +using System.Globalization; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Dispatcher.Patterns; +using Microsoft.Extensions.ObjectPool; + +namespace Microsoft.AspNetCore.Dispatcher +{ + public class RoutePatternBinder + { + private readonly UrlEncoder _urlEncoder; + private readonly ObjectPool _pool; + + private readonly DispatcherValueCollection _defaults; + private readonly DispatcherValueCollection _filters; + private readonly RoutePattern _pattern; + + public RoutePatternBinder( + UrlEncoder urlEncoder, + ObjectPool pool, + RoutePattern template, + DispatcherValueCollection defaults) + { + if (urlEncoder == null) + { + throw new ArgumentNullException(nameof(urlEncoder)); + } + + if (pool == null) + { + throw new ArgumentNullException(nameof(pool)); + } + + if (template == null) + { + throw new ArgumentNullException(nameof(template)); + } + + _urlEncoder = urlEncoder; + _pool = pool; + _pattern = template; + _defaults = defaults; + + // Any default that doesn't have a corresponding parameter is a 'filter' and if a value + // is provided for that 'filter' it must match the value in defaults. + _filters = new DispatcherValueCollection(_defaults); + foreach (var parameter in _pattern.Parameters) + { + _filters.Remove(parameter.Name); + } + } + + // Step 1: Get the list of values we're going to try to use to match and generate this URI + public (DispatcherValueCollection acceptedValues, DispatcherValueCollection combinedValues) GetValues(DispatcherValueCollection ambientValues, DispatcherValueCollection values) + { + var context = new TemplateBindingContext(_defaults); + + // Find out which entries in the URI are valid for the URI we want to generate. + // If the URI had ordered parameters a="1", b="2", c="3" and the new values + // specified that b="9", then we need to invalidate everything after it. The new + // values should then be a="1", b="9", c=. + // + // We also handle the case where a parameter is optional but has no value - we shouldn't + // accept additional parameters that appear *after* that parameter. + for (var i = 0; i < _pattern.Parameters.Count; i++) + { + var parameter = _pattern.Parameters[i]; + + // If it's a parameter subsegment, examine the current value to see if it matches the new value + var parameterName = parameter.Name; + + object newParameterValue; + var hasNewParameterValue = values.TryGetValue(parameterName, out newParameterValue); + + object currentParameterValue = null; + var hasCurrentParameterValue = ambientValues != null && + ambientValues.TryGetValue(parameterName, out currentParameterValue); + + if (hasNewParameterValue && hasCurrentParameterValue) + { + if (!RoutePartsEqual(currentParameterValue, newParameterValue)) + { + // Stop copying current values when we find one that doesn't match + break; + } + } + + if (!hasNewParameterValue && + !hasCurrentParameterValue && + _defaults?.ContainsKey(parameter.Name) != true) + { + // This is an unsatisfied parameter value and there are no defaults. We might still + // be able to generate a URL but we should stop 'accepting' ambient values. + // + // This might be a case like: + // template: a/{b?}/{c?} + // ambient: { c = 17 } + // values: { } + // + // We can still generate a URL from this ("/a") but we shouldn't accept 'c' because + // we can't use it. + // + // In the example above we should fall into this block for 'b'. + break; + } + + // If the parameter is a match, add it to the list of values we will use for URI generation + if (hasNewParameterValue) + { + if (IsRoutePartNonEmpty(newParameterValue)) + { + context.Accept(parameterName, newParameterValue); + } + } + else + { + if (hasCurrentParameterValue) + { + context.Accept(parameterName, currentParameterValue); + } + } + } + + // Add all remaining new values to the list of values we will use for URI generation + foreach (var kvp in values) + { + if (IsRoutePartNonEmpty(kvp.Value)) + { + context.Accept(kvp.Key, kvp.Value); + } + } + + // Accept all remaining default values if they match a required parameter + for (var i = 0; i < _pattern.Parameters.Count; i++) + { + var parameter = _pattern.Parameters[i]; + if (parameter.IsOptional || parameter.IsCatchAll) + { + continue; + } + + if (context.NeedsValue(parameter.Name)) + { + // Add the default value only if there isn't already a new value for it and + // only if it actually has a default value, which we determine based on whether + // the parameter value is required. + context.AcceptDefault(parameter.Name); + } + } + + // Validate that all required parameters have a value. + for (var i = 0; i < _pattern.Parameters.Count; i++) + { + var parameter = _pattern.Parameters[i]; + if (parameter.IsOptional || parameter.IsCatchAll) + { + continue; + } + + if (!context.AcceptedValues.ContainsKey(parameter.Name)) + { + // We don't have a value for this parameter, so we can't generate a url. + return (null, null); + } + } + + // Any default values that don't appear as parameters are treated like filters. Any new values + // provided must match these defaults. + foreach (var filter in _filters) + { + var parameter = _pattern.GetParameter(filter.Key); + if (parameter != null) + { + continue; + } + + object value; + if (values.TryGetValue(filter.Key, out value)) + { + if (!RoutePartsEqual(value, filter.Value)) + { + // If there is a non-parameterized value in the route and there is a + // new value for it and it doesn't match, this route won't match. + return (null, null); + } + } + } + + // Add any ambient values that don't match parameters - they need to be visible to constraints + // but they will ignored by link generation. + var combinedValues = new DispatcherValueCollection(context.AcceptedValues); + if (ambientValues != null) + { + foreach (var kvp in ambientValues) + { + if (IsRoutePartNonEmpty(kvp.Value)) + { + var parameter = _pattern.GetParameter(kvp.Key); + if (parameter == null && !context.AcceptedValues.ContainsKey(kvp.Key)) + { + combinedValues.Add(kvp.Key, kvp.Value); + } + } + } + } + + return (context.AcceptedValues, combinedValues); + } + + // Step 2: If the route is a match generate the appropriate URI + public string BindValues(DispatcherValueCollection acceptedValues) + { + var context = _pool.Get(); + var result = BindValues(context, acceptedValues); + _pool.Return(context); + return result; + } + + private string BindValues(UriBuildingContext context, DispatcherValueCollection acceptedValues) + { + for (var i = 0; i < _pattern.PathSegments.Count; i++) + { + Debug.Assert(context.BufferState == SegmentState.Beginning); + Debug.Assert(context.UriState == SegmentState.Beginning); + + var segment = _pattern.PathSegments[i]; + + for (var j = 0; j < segment.Parts.Count; j++) + { + var part = segment.Parts[j]; + + if (part.IsLiteral) + { + if (!context.Accept(_urlEncoder, ((RoutePatternLiteral)part).Content)) + { + return null; + } + } + else if (part.IsSeparator) + { + if (!context.Accept(_urlEncoder, ((RoutePatternSeparator)part).Content)) + { + return null; + } + } + else if (part.IsParameter && part is RoutePatternParameter parameter) + { + // If it's a parameter, get its value + object value; + var hasValue = acceptedValues.TryGetValue(parameter.Name, out value); + if (hasValue) + { + acceptedValues.Remove(parameter.Name); + } + + var isSameAsDefault = false; + object defaultValue; + if (_defaults != null && _defaults.TryGetValue(parameter.Name, out defaultValue)) + { + if (RoutePartsEqual(value, defaultValue)) + { + isSameAsDefault = true; + } + } + + var converted = Convert.ToString(value, CultureInfo.InvariantCulture); + if (isSameAsDefault) + { + // If the accepted value is the same as the default value buffer it since + // we won't necessarily add it to the URI we generate. + if (!context.Buffer(_urlEncoder, converted)) + { + return null; + } + } + else + { + // If the value is not accepted, it is null or empty value in the + // middle of the segment. We accept this if the parameter is an + // optional parameter and it is preceded by an optional seperator. + // I this case, we need to remove the optional seperator that we + // have added to the URI + // Example: template = {id}.{format?}. parameters: id=5 + // In this case after we have generated "5.", we wont find any value + // for format, so we remove '.' and generate 5. + if (!context.Accept(_urlEncoder, converted)) + { + if (j != 0 && parameter.IsOptional && segment.Parts[j - 1].IsSeparator) + { + context.Remove(((RoutePatternSeparator)segment.Parts[j - 1]).Content); + } + else + { + return null; + } + } + } + } + } + + context.EndSegment(); + } + + // Generate the query string from the remaining values + var wroteFirst = false; + foreach (var kvp in acceptedValues) + { + if (_defaults != null && _defaults.ContainsKey(kvp.Key)) + { + // This value is a 'filter' we don't need to put it in the query string. + continue; + } + + var values = kvp.Value as IEnumerable; + if (values != null && !(values is string)) + { + foreach (var value in values) + { + wroteFirst |= AddParameterToContext(context, kvp.Key, value, wroteFirst); + } + } + else + { + wroteFirst |= AddParameterToContext(context, kvp.Key, kvp.Value, wroteFirst); + } + } + return context.ToString(); + } + + private bool AddParameterToContext(UriBuildingContext context, string key, object value, bool wroteFirst) + { + var converted = Convert.ToString(value, CultureInfo.InvariantCulture); + if (!string.IsNullOrEmpty(converted)) + { + context.Writer.Write(wroteFirst ? '&' : '?'); + _urlEncoder.Encode(context.Writer, key); + context.Writer.Write('='); + _urlEncoder.Encode(context.Writer, converted); + return true; + } + return false; + } + + /// + /// Compares two objects for equality as parts of a case-insensitive path. + /// + /// An object to compare. + /// An object to compare. + /// True if the object are equal, otherwise false. + public static bool RoutePartsEqual(object a, object b) + { + var sa = a as string; + var sb = b as string; + + if (sa != null && sb != null) + { + // For strings do a case-insensitive comparison + return string.Equals(sa, sb, StringComparison.OrdinalIgnoreCase); + } + else + { + if (a != null && b != null) + { + // Explicitly call .Equals() in case it is overridden in the type + return a.Equals(b); + } + else + { + // At least one of them is null. Return true if they both are + return a == b; + } + } + } + + private static bool IsRoutePartNonEmpty(object routePart) + { + var routePartString = routePart as string; + if (routePartString == null) + { + return routePart != null; + } + else + { + return routePartString.Length > 0; + } + } + + [DebuggerDisplay("{DebuggerToString(),nq}")] + private struct TemplateBindingContext + { + private readonly DispatcherValueCollection _defaults; + private readonly DispatcherValueCollection _acceptedValues; + + public TemplateBindingContext(DispatcherValueCollection defaults) + { + _defaults = defaults; + + _acceptedValues = new DispatcherValueCollection(); + } + + public DispatcherValueCollection AcceptedValues + { + get { return _acceptedValues; } + } + + public void Accept(string key, object value) + { + if (!_acceptedValues.ContainsKey(key)) + { + _acceptedValues.Add(key, value); + } + } + + public void AcceptDefault(string key) + { + Debug.Assert(!_acceptedValues.ContainsKey(key)); + + object value; + if (_defaults != null && _defaults.TryGetValue(key, out value)) + { + _acceptedValues.Add(key, value); + } + } + + public bool NeedsValue(string key) + { + return !_acceptedValues.ContainsKey(key); + } + + private string DebuggerToString() + { + return string.Format("{{Accepted: '{0}'}}", string.Join(", ", _acceptedValues.Keys)); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Internal/SegmentState.cs b/src/Microsoft.AspNetCore.Dispatcher/SegmentState.cs similarity index 93% rename from src/Microsoft.AspNetCore.Routing/Internal/SegmentState.cs rename to src/Microsoft.AspNetCore.Dispatcher/SegmentState.cs index 35076a0678..9aa4469a27 100644 --- a/src/Microsoft.AspNetCore.Routing/Internal/SegmentState.cs +++ b/src/Microsoft.AspNetCore.Dispatcher/SegmentState.cs @@ -1,7 +1,7 @@ // 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. -namespace Microsoft.AspNetCore.Routing.Internal +namespace Microsoft.AspNetCore.Dispatcher { // Segments are treated as all-or-none. We should never output a partial segment. // If we add any subsegment of this segment to the generated URI, we have to add diff --git a/src/Microsoft.AspNetCore.Routing/Internal/UriBuilderContextPooledObjectPolicy.cs b/src/Microsoft.AspNetCore.Dispatcher/UriBuilderContextPooledObjectPolicy.cs similarity index 52% rename from src/Microsoft.AspNetCore.Routing/Internal/UriBuilderContextPooledObjectPolicy.cs rename to src/Microsoft.AspNetCore.Dispatcher/UriBuilderContextPooledObjectPolicy.cs index 0db44758d9..0e44bcb3f8 100644 --- a/src/Microsoft.AspNetCore.Routing/Internal/UriBuilderContextPooledObjectPolicy.cs +++ b/src/Microsoft.AspNetCore.Dispatcher/UriBuilderContextPooledObjectPolicy.cs @@ -1,29 +1,15 @@ // 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.Text.Encodings.Web; using Microsoft.Extensions.ObjectPool; -namespace Microsoft.AspNetCore.Routing.Internal +namespace Microsoft.AspNetCore.Dispatcher { public class UriBuilderContextPooledObjectPolicy : IPooledObjectPolicy { - private readonly UrlEncoder _encoder; - - public UriBuilderContextPooledObjectPolicy(UrlEncoder encoder) - { - if (encoder == null) - { - throw new ArgumentNullException(nameof(encoder)); - } - - _encoder = encoder; - } - public UriBuildingContext Create() { - return new UriBuildingContext(_encoder); + return new UriBuildingContext(); } public bool Return(UriBuildingContext obj) diff --git a/src/Microsoft.AspNetCore.Routing/Internal/UriBuildingContext.cs b/src/Microsoft.AspNetCore.Dispatcher/UriBuildingContext.cs similarity index 92% rename from src/Microsoft.AspNetCore.Routing/Internal/UriBuildingContext.cs rename to src/Microsoft.AspNetCore.Dispatcher/UriBuildingContext.cs index 3b78fe8c78..d9b58a88fa 100644 --- a/src/Microsoft.AspNetCore.Routing/Internal/UriBuildingContext.cs +++ b/src/Microsoft.AspNetCore.Dispatcher/UriBuildingContext.cs @@ -7,7 +7,7 @@ using System.IO; using System.Text; using System.Text.Encodings.Web; -namespace Microsoft.AspNetCore.Routing.Internal +namespace Microsoft.AspNetCore.Dispatcher { [DebuggerDisplay("{DebuggerToString(),nq}")] public class UriBuildingContext @@ -19,14 +19,12 @@ namespace Microsoft.AspNetCore.Routing.Internal // segment is in the middle of the uri. We don't know if we need to write it out - if it's // followed by other optional segments than we will just throw it away. private readonly List _buffer; - private readonly UrlEncoder _urlEncoder; private bool _hasEmptySegment; private int _lastValueOffset; - public UriBuildingContext(UrlEncoder urlEncoder) + public UriBuildingContext() { - _urlEncoder = urlEncoder; _uri = new StringBuilder(); _buffer = new List(); Writer = new StringWriter(_uri); @@ -42,7 +40,7 @@ namespace Microsoft.AspNetCore.Routing.Internal public TextWriter Writer { get; } - public bool Accept(string value) + public bool Accept(UrlEncoder encoder, string value) { if (string.IsNullOrEmpty(value)) { @@ -67,7 +65,7 @@ namespace Microsoft.AspNetCore.Routing.Internal { if (_buffer[i].RequiresEncoding) { - _urlEncoder.Encode(Writer, _buffer[i].Value); + encoder.Encode(Writer, _buffer[i].Value); } else { @@ -93,11 +91,11 @@ namespace Microsoft.AspNetCore.Routing.Internal if (_uri.Length == 0 && value.Length > 0 && value[0] == '/') { _uri.Append("/"); - _urlEncoder.Encode(Writer, value, 1, value.Length - 1); + encoder.Encode(Writer, value, 1, value.Length - 1); } else { - _urlEncoder.Encode(Writer, value); + encoder.Encode(Writer, value); } return true; @@ -110,7 +108,7 @@ namespace Microsoft.AspNetCore.Routing.Internal _lastValueOffset = -1; } - public bool Buffer(string value) + public bool Buffer(UrlEncoder encoder, string value) { if (string.IsNullOrEmpty(value)) { @@ -135,7 +133,7 @@ namespace Microsoft.AspNetCore.Routing.Internal { // We've already written part of this segment so there's no point in buffering, we need to // write out the rest or give up. - var result = Accept(value); + var result = Accept(encoder, value); // We've already checked the conditions that could result in a rejected part, so this should // always be true. diff --git a/src/Microsoft.AspNetCore.Routing/DependencyInjection/RoutingServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.Routing/DependencyInjection/RoutingServiceCollectionExtensions.cs index a0b2b6b891..5bca018618 100644 --- a/src/Microsoft.AspNetCore.Routing/DependencyInjection/RoutingServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.Routing/DependencyInjection/RoutingServiceCollectionExtensions.cs @@ -28,14 +28,11 @@ namespace Microsoft.Extensions.DependencyInjection throw new ArgumentNullException(nameof(services)); } + // Routing shares lots of infrastructure with the dispatcher. + services.AddDispatcher(); + services.TryAddTransient(); services.TryAddSingleton(UrlEncoder.Default); - services.TryAddSingleton>(s => - { - var provider = s.GetRequiredService(); - var encoder = s.GetRequiredService(); - return provider.Create(new UriBuilderContextPooledObjectPolicy(encoder)); - }); // The TreeRouteBuilder is a builder for creating routes, it should stay transient because it's // stateful. diff --git a/src/Microsoft.AspNetCore.Routing/RouteBase.cs b/src/Microsoft.AspNetCore.Routing/RouteBase.cs index 64a7be7693..9e4eaf530e 100644 --- a/src/Microsoft.AspNetCore.Routing/RouteBase.cs +++ b/src/Microsoft.AspNetCore.Routing/RouteBase.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Text.Encodings.Web; using System.Threading.Tasks; +using Microsoft.AspNetCore.Dispatcher; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing.Internal; using Microsoft.AspNetCore.Routing.Logging; diff --git a/src/Microsoft.AspNetCore.Routing/Template/TemplateBinder.cs b/src/Microsoft.AspNetCore.Routing/Template/TemplateBinder.cs index 68770fd2bc..b20f461aa5 100644 --- a/src/Microsoft.AspNetCore.Routing/Template/TemplateBinder.cs +++ b/src/Microsoft.AspNetCore.Routing/Template/TemplateBinder.cs @@ -2,23 +2,15 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections; -using System.Diagnostics; -using System.Globalization; using System.Text.Encodings.Web; -using Microsoft.AspNetCore.Routing.Internal; +using Microsoft.AspNetCore.Dispatcher; using Microsoft.Extensions.ObjectPool; namespace Microsoft.AspNetCore.Routing.Template { public class TemplateBinder { - private readonly UrlEncoder _urlEncoder; - private readonly ObjectPool _pool; - - private readonly RouteValueDictionary _defaults; - private readonly RouteValueDictionary _filters; - private readonly RouteTemplate _template; + private readonly RoutePatternBinder _binder; public TemplateBinder( UrlEncoder urlEncoder, @@ -41,320 +33,29 @@ namespace Microsoft.AspNetCore.Routing.Template throw new ArgumentNullException(nameof(template)); } - _urlEncoder = urlEncoder; - _pool = pool; - _template = template; - _defaults = defaults; - - // Any default that doesn't have a corresponding parameter is a 'filter' and if a value - // is provided for that 'filter' it must match the value in defaults. - _filters = new RouteValueDictionary(_defaults); - foreach (var parameter in _template.Parameters) - { - _filters.Remove(parameter.Name); - } + _binder = new RoutePatternBinder(urlEncoder, pool, template.ToRoutePattern(), defaults); } // Step 1: Get the list of values we're going to try to use to match and generate this URI public TemplateValuesResult GetValues(RouteValueDictionary ambientValues, RouteValueDictionary values) { - var context = new TemplateBindingContext(_defaults); - - // Find out which entries in the URI are valid for the URI we want to generate. - // If the URI had ordered parameters a="1", b="2", c="3" and the new values - // specified that b="9", then we need to invalidate everything after it. The new - // values should then be a="1", b="9", c=. - // - // We also handle the case where a parameter is optional but has no value - we shouldn't - // accept additional parameters that appear *after* that parameter. - for (var i = 0; i < _template.Parameters.Count; i++) + (var acceptedValues, var combinedValues) = _binder.GetValues(ambientValues, values); + if (acceptedValues == null || combinedValues == null) { - var parameter = _template.Parameters[i]; - - // If it's a parameter subsegment, examine the current value to see if it matches the new value - var parameterName = parameter.Name; - - object newParameterValue; - var hasNewParameterValue = values.TryGetValue(parameterName, out newParameterValue); - - object currentParameterValue = null; - var hasCurrentParameterValue = ambientValues != null && - ambientValues.TryGetValue(parameterName, out currentParameterValue); - - if (hasNewParameterValue && hasCurrentParameterValue) - { - if (!RoutePartsEqual(currentParameterValue, newParameterValue)) - { - // Stop copying current values when we find one that doesn't match - break; - } - } - - if (!hasNewParameterValue && - !hasCurrentParameterValue && - _defaults?.ContainsKey(parameter.Name) != true) - { - // This is an unsatisfied parameter value and there are no defaults. We might still - // be able to generate a URL but we should stop 'accepting' ambient values. - // - // This might be a case like: - // template: a/{b?}/{c?} - // ambient: { c = 17 } - // values: { } - // - // We can still generate a URL from this ("/a") but we shouldn't accept 'c' because - // we can't use it. - // - // In the example above we should fall into this block for 'b'. - break; - } - - // If the parameter is a match, add it to the list of values we will use for URI generation - if (hasNewParameterValue) - { - if (IsRoutePartNonEmpty(newParameterValue)) - { - context.Accept(parameterName, newParameterValue); - } - } - else - { - if (hasCurrentParameterValue) - { - context.Accept(parameterName, currentParameterValue); - } - } - } - - // Add all remaining new values to the list of values we will use for URI generation - foreach (var kvp in values) - { - if (IsRoutePartNonEmpty(kvp.Value)) - { - context.Accept(kvp.Key, kvp.Value); - } - } - - // Accept all remaining default values if they match a required parameter - for (var i = 0; i < _template.Parameters.Count; i++) - { - var parameter = _template.Parameters[i]; - if (parameter.IsOptional || parameter.IsCatchAll) - { - continue; - } - - if (context.NeedsValue(parameter.Name)) - { - // Add the default value only if there isn't already a new value for it and - // only if it actually has a default value, which we determine based on whether - // the parameter value is required. - context.AcceptDefault(parameter.Name); - } - } - - // Validate that all required parameters have a value. - for (var i = 0; i < _template.Parameters.Count; i++) - { - var parameter = _template.Parameters[i]; - if (parameter.IsOptional || parameter.IsCatchAll) - { - continue; - } - - if (!context.AcceptedValues.ContainsKey(parameter.Name)) - { - // We don't have a value for this parameter, so we can't generate a url. - return null; - } - } - - // Any default values that don't appear as parameters are treated like filters. Any new values - // provided must match these defaults. - foreach (var filter in _filters) - { - var parameter = GetParameter(filter.Key); - if (parameter != null) - { - continue; - } - - object value; - if (values.TryGetValue(filter.Key, out value)) - { - if (!RoutePartsEqual(value, filter.Value)) - { - // If there is a non-parameterized value in the route and there is a - // new value for it and it doesn't match, this route won't match. - return null; - } - } - } - - // Add any ambient values that don't match parameters - they need to be visible to constraints - // but they will ignored by link generation. - var combinedValues = new RouteValueDictionary(context.AcceptedValues); - if (ambientValues != null) - { - foreach (var kvp in ambientValues) - { - if (IsRoutePartNonEmpty(kvp.Value)) - { - var parameter = GetParameter(kvp.Key); - if (parameter == null && !context.AcceptedValues.ContainsKey(kvp.Key)) - { - combinedValues.Add(kvp.Key, kvp.Value); - } - } - } + return null; } return new TemplateValuesResult() { - AcceptedValues = context.AcceptedValues, - CombinedValues = combinedValues, + AcceptedValues = acceptedValues.AsRouteValueDictionary(), + CombinedValues = combinedValues.AsRouteValueDictionary(), }; } // Step 2: If the route is a match generate the appropriate URI public string BindValues(RouteValueDictionary acceptedValues) { - var context = _pool.Get(); - var result = BindValues(context, acceptedValues); - _pool.Return(context); - return result; - } - - private string BindValues(UriBuildingContext context, RouteValueDictionary acceptedValues) - { - for (var i = 0; i < _template.Segments.Count; i++) - { - Debug.Assert(context.BufferState == SegmentState.Beginning); - Debug.Assert(context.UriState == SegmentState.Beginning); - - var segment = _template.Segments[i]; - - for (var j = 0; j < segment.Parts.Count; j++) - { - var part = segment.Parts[j]; - - if (part.IsLiteral) - { - if (!context.Accept(part.Text)) - { - return null; - } - } - else if (part.IsParameter) - { - // If it's a parameter, get its value - object value; - var hasValue = acceptedValues.TryGetValue(part.Name, out value); - if (hasValue) - { - acceptedValues.Remove(part.Name); - } - - var isSameAsDefault = false; - object defaultValue; - if (_defaults != null && _defaults.TryGetValue(part.Name, out defaultValue)) - { - if (RoutePartsEqual(value, defaultValue)) - { - isSameAsDefault = true; - } - } - - var converted = Convert.ToString(value, CultureInfo.InvariantCulture); - if (isSameAsDefault) - { - // If the accepted value is the same as the default value buffer it since - // we won't necessarily add it to the URI we generate. - if (!context.Buffer(converted)) - { - return null; - } - } - else - { - // If the value is not accepted, it is null or empty value in the - // middle of the segment. We accept this if the parameter is an - // optional parameter and it is preceded by an optional seperator. - // I this case, we need to remove the optional seperator that we - // have added to the URI - // Example: template = {id}.{format?}. parameters: id=5 - // In this case after we have generated "5.", we wont find any value - // for format, so we remove '.' and generate 5. - if (!context.Accept(converted)) - { - if (j != 0 && part.IsOptional && segment.Parts[j - 1].IsOptionalSeperator) - { - context.Remove(segment.Parts[j - 1].Text); - } - else - { - return null; - } - } - } - } - } - - context.EndSegment(); - } - - // Generate the query string from the remaining values - var wroteFirst = false; - foreach (var kvp in acceptedValues) - { - if (_defaults != null && _defaults.ContainsKey(kvp.Key)) - { - // This value is a 'filter' we don't need to put it in the query string. - continue; - } - - var values = kvp.Value as IEnumerable; - if (values != null && !(values is string)) - { - foreach (var value in values) - { - wroteFirst |= AddParameterToContext(context, kvp.Key, value, wroteFirst); - } - } - else - { - wroteFirst |= AddParameterToContext(context, kvp.Key, kvp.Value, wroteFirst); - } - } - return context.ToString(); - } - - private bool AddParameterToContext(UriBuildingContext context, string key, object value, bool wroteFirst) - { - var converted = Convert.ToString(value, CultureInfo.InvariantCulture); - if (!string.IsNullOrEmpty(converted)) - { - context.Writer.Write(wroteFirst ? '&' : '?'); - _urlEncoder.Encode(context.Writer, key); - context.Writer.Write('='); - _urlEncoder.Encode(context.Writer, converted); - return true; - } - return false; - } - - private TemplatePart GetParameter(string name) - { - for (var i = 0; i < _template.Parameters.Count; i++) - { - var parameter = _template.Parameters[i]; - if (string.Equals(parameter.Name, name, StringComparison.OrdinalIgnoreCase)) - { - return parameter; - } - } - - return null; + return _binder.BindValues(acceptedValues); } /// @@ -387,66 +88,5 @@ namespace Microsoft.AspNetCore.Routing.Template } } } - - private static bool IsRoutePartNonEmpty(object routePart) - { - var routePartString = routePart as string; - if (routePartString == null) - { - return routePart != null; - } - else - { - return routePartString.Length > 0; - } - } - - [DebuggerDisplay("{DebuggerToString(),nq}")] - private struct TemplateBindingContext - { - private readonly RouteValueDictionary _defaults; - private readonly RouteValueDictionary _acceptedValues; - - public TemplateBindingContext(RouteValueDictionary defaults) - { - _defaults = defaults; - - _acceptedValues = new RouteValueDictionary(); - } - - public RouteValueDictionary AcceptedValues - { - get { return _acceptedValues; } - } - - public void Accept(string key, object value) - { - if (!_acceptedValues.ContainsKey(key)) - { - _acceptedValues.Add(key, value); - } - } - - public void AcceptDefault(string key) - { - Debug.Assert(!_acceptedValues.ContainsKey(key)); - - object value; - if (_defaults != null && _defaults.TryGetValue(key, out value)) - { - _acceptedValues.Add(key, value); - } - } - - public bool NeedsValue(string key) - { - return !_acceptedValues.ContainsKey(key); - } - - private string DebuggerToString() - { - return string.Format("{{Accepted: '{0}'}}", string.Join(", ", _acceptedValues.Keys)); - } - } } } diff --git a/src/Microsoft.AspNetCore.Routing/Tree/TreeRouteBuilder.cs b/src/Microsoft.AspNetCore.Routing/Tree/TreeRouteBuilder.cs index a2521c02e4..622f099604 100644 --- a/src/Microsoft.AspNetCore.Routing/Tree/TreeRouteBuilder.cs +++ b/src/Microsoft.AspNetCore.Routing/Tree/TreeRouteBuilder.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text.Encodings.Web; -using Microsoft.AspNetCore.Routing.Internal; +using Microsoft.AspNetCore.Dispatcher; using Microsoft.AspNetCore.Routing.Template; using Microsoft.Extensions.Logging; using Microsoft.Extensions.ObjectPool; diff --git a/src/Microsoft.AspNetCore.Routing/Tree/TreeRouter.cs b/src/Microsoft.AspNetCore.Routing/Tree/TreeRouter.cs index c8426851ad..5f9bc989ed 100644 --- a/src/Microsoft.AspNetCore.Routing/Tree/TreeRouter.cs +++ b/src/Microsoft.AspNetCore.Routing/Tree/TreeRouter.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Text.Encodings.Web; using System.Threading.Tasks; +using Microsoft.AspNetCore.Dispatcher; using Microsoft.AspNetCore.Dispatcher.Internal; using Microsoft.AspNetCore.Routing.Internal; using Microsoft.AspNetCore.Routing.Logging; diff --git a/src/Microsoft.AspNetCore.Routing/breakingchanges.netcore.json b/src/Microsoft.AspNetCore.Routing/breakingchanges.netcore.json new file mode 100644 index 0000000000..a53d01d9df --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/breakingchanges.netcore.json @@ -0,0 +1,17 @@ +[ + { + "TypeId": "public class Microsoft.AspNetCore.Routing.Tree.TreeRouter : Microsoft.AspNetCore.Routing.IRouter", + "MemberId": "public .ctor(Microsoft.AspNetCore.Routing.Tree.UrlMatchingTree[] trees, System.Collections.Generic.IEnumerable linkGenerationEntries, System.Text.Encodings.Web.UrlEncoder urlEncoder, Microsoft.Extensions.ObjectPool.ObjectPool objectPool, Microsoft.Extensions.Logging.ILogger routeLogger, Microsoft.Extensions.Logging.ILogger constraintLogger, System.Int32 version)", + "Kind": "Removal" + }, + { + "TypeId": "public class Microsoft.AspNetCore.Routing.Tree.TreeRouteBuilder", + "MemberId": "public .ctor(Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, System.Text.Encodings.Web.UrlEncoder urlEncoder, Microsoft.Extensions.ObjectPool.ObjectPool objectPool, Microsoft.AspNetCore.Routing.IInlineConstraintResolver constraintResolver)", + "Kind": "Removal" + }, + { + "TypeId": "public class Microsoft.AspNetCore.Routing.Template.TemplateBinder", + "MemberId": "public .ctor(System.Text.Encodings.Web.UrlEncoder urlEncoder, Microsoft.Extensions.ObjectPool.ObjectPool pool, Microsoft.AspNetCore.Routing.Template.RouteTemplate template, Microsoft.AspNetCore.Routing.RouteValueDictionary defaults)", + "Kind": "Removal" + } +] diff --git a/test/Microsoft.AspNetCore.Dispatcher.Test/Microsoft.AspNetCore.Dispatcher.Test.csproj b/test/Microsoft.AspNetCore.Dispatcher.Test/Microsoft.AspNetCore.Dispatcher.Test.csproj index 876d17b915..2e2d8867db 100644 --- a/test/Microsoft.AspNetCore.Dispatcher.Test/Microsoft.AspNetCore.Dispatcher.Test.csproj +++ b/test/Microsoft.AspNetCore.Dispatcher.Test/Microsoft.AspNetCore.Dispatcher.Test.csproj @@ -13,6 +13,8 @@ + + diff --git a/test/Microsoft.AspNetCore.Dispatcher.Test/TemplateBinderTests.cs b/test/Microsoft.AspNetCore.Dispatcher.Test/TemplateBinderTests.cs new file mode 100644 index 0000000000..7e41f76f69 --- /dev/null +++ b/test/Microsoft.AspNetCore.Dispatcher.Test/TemplateBinderTests.cs @@ -0,0 +1,1244 @@ +// 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 System.Text.Encodings.Web; +using Microsoft.AspNetCore.Dispatcher.Patterns; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ObjectPool; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.WebEncoders.Testing; +using Xunit; + +namespace Microsoft.AspNetCore.Dispatcher +{ + public class RoutePatternBinderTests + { + public static TheoryData EmptyAndNullDefaultValues => + new TheoryData + { + { + "Test/{val1}/{val2}", + new DispatcherValueCollection(new {val1 = "", val2 = ""}), + new DispatcherValueCollection(new {val2 = "SomeVal2"}), + null + }, + { + "Test/{val1}/{val2}", + new DispatcherValueCollection(new {val1 = "", val2 = ""}), + new DispatcherValueCollection(new {val1 = "a"}), + "/UrlEncode[[Test]]/UrlEncode[[a]]" + }, + { + "Test/{val1}/{val2}/{val3}", + new DispatcherValueCollection(new {val1 = "", val3 = ""}), + new DispatcherValueCollection(new {val2 = "a"}), + null + }, + { + "Test/{val1}/{val2}", + new DispatcherValueCollection(new {val1 = "", val2 = ""}), + new DispatcherValueCollection(new {val1 = "a", val2 = "b"}), + "/UrlEncode[[Test]]/UrlEncode[[a]]/UrlEncode[[b]]" + }, + { + "Test/{val1}/{val2}/{val3}", + new DispatcherValueCollection(new {val1 = "", val2 = "", val3 = ""}), + new DispatcherValueCollection(new {val1 = "a", val2 = "b", val3 = "c"}), + "/UrlEncode[[Test]]/UrlEncode[[a]]/UrlEncode[[b]]/UrlEncode[[c]]" + }, + { + "Test/{val1}/{val2}/{val3}", + new DispatcherValueCollection(new {val1 = "", val2 = "", val3 = ""}), + new DispatcherValueCollection(new {val1 = "a", val2 = "b"}), + "/UrlEncode[[Test]]/UrlEncode[[a]]/UrlEncode[[b]]" + }, + { + "Test/{val1}/{val2}/{val3}", + new DispatcherValueCollection(new {val1 = "", val2 = "", val3 = ""}), + new DispatcherValueCollection(new {val1 = "a"}), + "/UrlEncode[[Test]]/UrlEncode[[a]]" + }, + { + "Test/{val1}", + new DispatcherValueCollection(new {val1 = "42", val2 = "", val3 = ""}), + new DispatcherValueCollection(), + "/UrlEncode[[Test]]" + }, + { + "Test/{val1}/{val2}/{val3}", + new DispatcherValueCollection(new {val1 = "42", val2 = (string)null, val3 = (string)null}), + new DispatcherValueCollection(), + "/UrlEncode[[Test]]" + }, + { + "Test/{val1}/{val2}/{val3}/{val4}", + new DispatcherValueCollection(new {val1 = "21", val2 = "", val3 = "", val4 = ""}), + new DispatcherValueCollection(new {val1 = "42", val2 = "11", val3 = "", val4 = ""}), + "/UrlEncode[[Test]]/UrlEncode[[42]]/UrlEncode[[11]]" + }, + { + "Test/{val1}/{val2}/{val3}", + new DispatcherValueCollection(new {val1 = "21", val2 = "", val3 = ""}), + new DispatcherValueCollection(new {val1 = "42"}), + "/UrlEncode[[Test]]/UrlEncode[[42]]" + }, + { + "Test/{val1}/{val2}/{val3}/{val4}", + new DispatcherValueCollection(new {val1 = "21", val2 = "", val3 = "", val4 = ""}), + new DispatcherValueCollection(new {val1 = "42", val2 = "11"}), + "/UrlEncode[[Test]]/UrlEncode[[42]]/UrlEncode[[11]]" + }, + { + "Test/{val1}/{val2}/{val3}", + new DispatcherValueCollection(new {val1 = "21", val2 = (string)null, val3 = (string)null}), + new DispatcherValueCollection(new {val1 = "42"}), + "/UrlEncode[[Test]]/UrlEncode[[42]]" + }, + { + "Test/{val1}/{val2}/{val3}/{val4}", + new DispatcherValueCollection(new {val1 = "21", val2 = (string)null, val3 = (string)null, val4 = (string)null}), + new DispatcherValueCollection(new {val1 = "42", val2 = "11"}), + "/UrlEncode[[Test]]/UrlEncode[[42]]/UrlEncode[[11]]" + }, + }; + + [Theory] + [MemberData(nameof(EmptyAndNullDefaultValues))] + public void Binding_WithEmptyAndNull_DefaultValues( + string pattern, + DispatcherValueCollection defaults, + DispatcherValueCollection values, + string expected) + { + // Arrange + var encoder = new UrlTestEncoder(); + var binder = new RoutePatternBinder( + encoder, + new DefaultObjectPoolProvider().Create(new UriBuilderContextPooledObjectPolicy()), + RoutePattern.Parse(pattern), + defaults); + + // Act & Assert + (var acceptedValues, var combinedValues) = binder.GetValues(ambientValues: null, values: values); + if (acceptedValues == null) + { + if (expected == null) + { + return; + } + else + { + Assert.NotNull(acceptedValues); + } + } + + var result = binder.BindValues(acceptedValues); + if (expected == null) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + Assert.Equal(expected, result); + } + } + + [Fact] + public void GetVirtualPathWithMultiSegmentParamsOnBothEndsMatches() + { + RunTest( + "language/{lang}-{region}", + null, + new DispatcherValueCollection(new { lang = "en", region = "US" }), + new DispatcherValueCollection(new { lang = "xx", region = "yy" }), + "/UrlEncode[[language]]/UrlEncode[[xx]]UrlEncode[[-]]UrlEncode[[yy]]"); + } + + [Fact] + public void GetVirtualPathWithMultiSegmentParamsOnLeftEndMatches() + { + RunTest( + "language/{lang}-{region}a", + null, + new DispatcherValueCollection(new { lang = "en", region = "US" }), + new DispatcherValueCollection(new { lang = "xx", region = "yy" }), + "/UrlEncode[[language]]/UrlEncode[[xx]]UrlEncode[[-]]UrlEncode[[yy]]UrlEncode[[a]]"); + } + + [Fact] + public void GetVirtualPathWithMultiSegmentParamsOnRightEndMatches() + { + RunTest( + "language/a{lang}-{region}", + null, + new DispatcherValueCollection(new { lang = "en", region = "US" }), + new DispatcherValueCollection(new { lang = "xx", region = "yy" }), + "/UrlEncode[[language]]/UrlEncode[[a]]UrlEncode[[xx]]UrlEncode[[-]]UrlEncode[[yy]]"); + } + + public static TheoryData OptionalParamValues => + new TheoryData + { + // defaults + // ambient values + // values + { + "Test/{val1}/{val2}.{val3?}", + new DispatcherValueCollection(new {val1 = "someval1", val2 = "someval2"}), + new DispatcherValueCollection(new {val3 = "someval3"}), + new DispatcherValueCollection(new {val3 = "someval3"}), + "/UrlEncode[[Test]]/UrlEncode[[someval1]]/UrlEncode[[someval2]]UrlEncode[[.]]UrlEncode[[someval3]]" + }, + { + "Test/{val1}/{val2}.{val3?}", + new DispatcherValueCollection(new {val1 = "someval1", val2 = "someval2"}), + new DispatcherValueCollection(new {val3 = "someval3a"}), + new DispatcherValueCollection(new {val3 = "someval3v"}), + "/UrlEncode[[Test]]/UrlEncode[[someval1]]/UrlEncode[[someval2]]UrlEncode[[.]]UrlEncode[[someval3v]]" + }, + { + "Test/{val1}/{val2}.{val3?}", + new DispatcherValueCollection(new {val1 = "someval1", val2 = "someval2"}), + new DispatcherValueCollection(new {val3 = "someval3a"}), + new DispatcherValueCollection(), + "/UrlEncode[[Test]]/UrlEncode[[someval1]]/UrlEncode[[someval2]]UrlEncode[[.]]UrlEncode[[someval3a]]" + }, + { + "Test/{val1}/{val2}.{val3?}", + new DispatcherValueCollection(new {val1 = "someval1", val2 = "someval2"}), + new DispatcherValueCollection(), + new DispatcherValueCollection(new {val3 = "someval3v"}), + "/UrlEncode[[Test]]/UrlEncode[[someval1]]/UrlEncode[[someval2]]UrlEncode[[.]]UrlEncode[[someval3v]]" + }, + { + "Test/{val1}/{val2}.{val3?}", + new DispatcherValueCollection(new {val1 = "someval1", val2 = "someval2"}), + new DispatcherValueCollection(), + new DispatcherValueCollection(), + "/UrlEncode[[Test]]/UrlEncode[[someval1]]/UrlEncode[[someval2]]" + }, + { + "Test/{val1}.{val2}.{val3}.{val4?}", + new DispatcherValueCollection(new {val1 = "someval1", val2 = "someval2" }), + new DispatcherValueCollection(), + new DispatcherValueCollection(new {val4 = "someval4", val3 = "someval3" }), + "/UrlEncode[[Test]]/UrlEncode[[someval1]]UrlEncode[[.]]UrlEncode[[someval2]]UrlEncode[[.]]" + + "UrlEncode[[someval3]]UrlEncode[[.]]UrlEncode[[someval4]]" + }, + { + "Test/{val1}.{val2}.{val3}.{val4?}", + new DispatcherValueCollection(new {val1 = "someval1", val2 = "someval2" }), + new DispatcherValueCollection(), + new DispatcherValueCollection(new {val3 = "someval3" }), + "/UrlEncode[[Test]]/UrlEncode[[someval1]]UrlEncode[[.]]UrlEncode[[someval2]]UrlEncode[[.]]" + + "UrlEncode[[someval3]]" + }, + { + "Test/.{val2?}", + new DispatcherValueCollection(new { }), + new DispatcherValueCollection(), + new DispatcherValueCollection(new {val2 = "someval2" }), + "/UrlEncode[[Test]]/UrlEncode[[.]]UrlEncode[[someval2]]" + }, + { + "Test/{val1}.{val2}", + new DispatcherValueCollection(new {val1 = "someval1", val2 = "someval2" }), + new DispatcherValueCollection(), + new DispatcherValueCollection(new {val3 = "someval3" }), + "/UrlEncode[[Test]]/UrlEncode[[someval1]]UrlEncode[[.]]UrlEncode[[someval2]]?" + + "UrlEncode[[val3]]=UrlEncode[[someval3]]" + }, + }; + + [Theory] + [MemberData(nameof(OptionalParamValues))] + public void GetVirtualPathWithMultiSegmentWithOptionalParam( + string pattern, + DispatcherValueCollection defaults, + DispatcherValueCollection ambientValues, + DispatcherValueCollection values, + string expected) + { + // Arrange + var encoder = new UrlTestEncoder(); + var binder = new RoutePatternBinder( + encoder, + new DefaultObjectPoolProvider().Create(new UriBuilderContextPooledObjectPolicy()), + RoutePattern.Parse(pattern), + defaults); + + // Act & Assert + (var acceptedValues, var combinedValues) = binder.GetValues(ambientValues: ambientValues, values: values); + if (acceptedValues == null) + { + if (expected == null) + { + return; + } + else + { + Assert.NotNull(acceptedValues); + } + } + + var result = binder.BindValues(acceptedValues); + if (expected == null) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + Assert.Equal(expected, result); + } + } + + [Fact] + public void GetVirtualPathWithMultiSegmentParamsOnNeitherEndMatches() + { + RunTest( + "language/a{lang}-{region}a", + null, + new DispatcherValueCollection(new { lang = "en", region = "US" }), + new DispatcherValueCollection(new { lang = "xx", region = "yy" }), + "/UrlEncode[[language]]/UrlEncode[[a]]UrlEncode[[xx]]UrlEncode[[-]]UrlEncode[[yy]]UrlEncode[[a]]"); + } + + [Fact] + public void GetVirtualPathWithMultiSegmentParamsOnNeitherEndDoesNotMatch() + { + RunTest( + "language/a{lang}-{region}a", + null, + new DispatcherValueCollection(new { lang = "en", region = "US" }), + new DispatcherValueCollection(new { lang = "", region = "yy" }), + null); + } + + [Fact] + public void GetVirtualPathWithMultiSegmentParamsOnNeitherEndDoesNotMatch2() + { + RunTest( + "language/a{lang}-{region}a", + null, + new DispatcherValueCollection(new { lang = "en", region = "US" }), + new DispatcherValueCollection(new { lang = "xx", region = "" }), + null); + } + + [Fact] + public void GetVirtualPathWithSimpleMultiSegmentParamsOnBothEndsMatches() + { + RunTest( + "language/{lang}", + null, + new DispatcherValueCollection(new { lang = "en" }), + new DispatcherValueCollection(new { lang = "xx" }), + "/UrlEncode[[language]]/UrlEncode[[xx]]"); + } + + [Fact] + public void GetVirtualPathWithSimpleMultiSegmentParamsOnLeftEndMatches() + { + RunTest( + "language/{lang}-", + null, + new DispatcherValueCollection(new { lang = "en" }), + new DispatcherValueCollection(new { lang = "xx" }), + "/UrlEncode[[language]]/UrlEncode[[xx]]UrlEncode[[-]]"); + } + + [Fact] + public void GetVirtualPathWithSimpleMultiSegmentParamsOnRightEndMatches() + { + RunTest( + "language/a{lang}", + null, + new DispatcherValueCollection(new { lang = "en" }), + new DispatcherValueCollection(new { lang = "xx" }), + "/UrlEncode[[language]]/UrlEncode[[a]]UrlEncode[[xx]]"); + } + + [Fact] + public void GetVirtualPathWithSimpleMultiSegmentParamsOnNeitherEndMatches() + { + RunTest( + "language/a{lang}a", + null, + new DispatcherValueCollection(new { lang = "en" }), + new DispatcherValueCollection(new { lang = "xx" }), + "/UrlEncode[[language]]/UrlEncode[[a]]UrlEncode[[xx]]UrlEncode[[a]]"); + } + + [Fact] + public void GetVirtualPathWithMultiSegmentStandardMvcRouteMatches() + { + RunTest( + "{controller}.mvc/{action}/{id}", + new DispatcherValueCollection(new { action = "Index", id = (string)null }), + new DispatcherValueCollection(new { controller = "home", action = "list", id = (string)null }), + new DispatcherValueCollection(new { controller = "products" }), + "/UrlEncode[[products]]UrlEncode[[.mvc]]"); + } + + [Fact] + public void GetVirtualPathWithMultiSegmentParamsOnBothEndsWithDefaultValuesMatches() + { + RunTest( + "language/{lang}-{region}", + new DispatcherValueCollection(new { lang = "xx", region = "yy" }), + new DispatcherValueCollection(new { lang = "en", region = "US" }), + new DispatcherValueCollection(new { lang = "zz" }), + "/UrlEncode[[language]]/UrlEncode[[zz]]UrlEncode[[-]]UrlEncode[[yy]]"); + } + + [Fact] + public void GetUrlWithDefaultValue() + { + // URL should be found but excluding the 'id' parameter, which has only a default value. + RunTest( + "{controller}/{action}/{id}", + new DispatcherValueCollection(new { id = "defaultid" }), + new DispatcherValueCollection(new { controller = "home", action = "oldaction" }), + new DispatcherValueCollection(new { action = "newaction" }), + "/UrlEncode[[home]]/UrlEncode[[newaction]]"); + } + + [Fact] + public void GetVirtualPathWithEmptyStringRequiredValueReturnsNull() + { + RunTest( + "foo/{controller}", + null, + new DispatcherValueCollection(new { }), + new DispatcherValueCollection(new { controller = "" }), + null); + } + + [Fact] + public void GetVirtualPathWithNullRequiredValueReturnsNull() + { + RunTest( + "foo/{controller}", + null, + new DispatcherValueCollection(new { }), + new DispatcherValueCollection(new { controller = (string)null }), + null); + } + + [Fact] + public void GetVirtualPathWithRequiredValueReturnsPath() + { + RunTest( + "foo/{controller}", + null, + new DispatcherValueCollection(new { }), + new DispatcherValueCollection(new { controller = "home" }), + "/UrlEncode[[foo]]/UrlEncode[[home]]"); + } + + [Fact] + public void GetUrlWithNullDefaultValue() + { + // URL should be found but excluding the 'id' parameter, which has only a default value. + RunTest( + "{controller}/{action}/{id}", + new DispatcherValueCollection(new { id = (string)null }), + new DispatcherValueCollection(new { controller = "home", action = "oldaction", id = (string)null }), + new DispatcherValueCollection(new { action = "newaction" }), + "/UrlEncode[[home]]/UrlEncode[[newaction]]"); + } + + [Fact] + public void GetVirtualPathCanFillInSeparatedParametersWithDefaultValues() + { + RunTest( + "{controller}/{language}-{locale}", + new DispatcherValueCollection(new { language = "en", locale = "US" }), + new DispatcherValueCollection(), + new DispatcherValueCollection(new { controller = "Orders" }), + "/UrlEncode[[Orders]]/UrlEncode[[en]]UrlEncode[[-]]UrlEncode[[US]]"); + } + + [Fact] + public void GetVirtualPathWithUnusedNullValueShouldGenerateUrlAndIgnoreNullValue() + { + RunTest( + "{controller}.mvc/{action}/{id}", + new DispatcherValueCollection(new { action = "Index", id = "" }), + new DispatcherValueCollection(new { controller = "Home", action = "Index", id = "" }), + new DispatcherValueCollection(new { controller = "Home", action = "TestAction", id = "1", format = (string)null }), + "/UrlEncode[[Home]]UrlEncode[[.mvc]]/UrlEncode[[TestAction]]/UrlEncode[[1]]"); + } + + [Fact] + public void GetUrlWithMissingValuesDoesntMatch() + { + RunTest( + "{controller}/{action}/{id}", + null, + new { controller = "home", action = "oldaction" }, + new { action = "newaction" }, + null); + } + + [Fact] + public void GetUrlWithEmptyRequiredValuesReturnsNull() + { + RunTest( + "{p1}/{p2}/{p3}", + null, + new { p1 = "v1", }, + new { p2 = "", p3 = "" }, + null); + } + + [Fact] + public void GetUrlWithEmptyOptionalValuesReturnsShortUrl() + { + RunTest( + "{p1}/{p2}/{p3}", + new { p2 = "d2", p3 = "d3" }, + new { p1 = "v1", }, + new { p2 = "", p3 = "" }, + "/UrlEncode[[v1]]"); + } + + [Fact] + public void GetUrlShouldIgnoreValuesAfterChangedParameter() + { + RunTest( + "{controller}/{action}/{id}", + new { action = "Index", id = (string)null }, + new { controller = "orig", action = "init", id = "123" }, + new { action = "new", }, + "/UrlEncode[[orig]]/UrlEncode[[new]]"); + } + + [Fact] + public void GetUrlWithNullForMiddleParameterIgnoresRemainingParameters() + { + RunTest( + "UrlGeneration1/{controller}.mvc/{action}/{category}/{year}/{occasion}/{SafeParam}", + new { year = 1995, occasion = "Christmas", action = "Play", SafeParam = "SafeParamValue" }, + new { controller = "UrlRouting", action = "Play", category = "Photos", year = "2008", occasion = "Easter", SafeParam = "SafeParamValue" }, + new { year = (string)null, occasion = "Hola" }, + "/UrlEncode[[UrlGeneration1]]/UrlEncode[[UrlRouting]]UrlEncode[[.mvc]]/UrlEncode[[Play]]/" + + "UrlEncode[[Photos]]/UrlEncode[[1995]]/UrlEncode[[Hola]]"); + } + + [Fact] + public void GetUrlWithEmptyStringForMiddleParameterIgnoresRemainingParameters() + { + var ambientValues = new DispatcherValueCollection(); + ambientValues.Add("controller", "UrlRouting"); + ambientValues.Add("action", "Play"); + ambientValues.Add("category", "Photos"); + ambientValues.Add("year", "2008"); + ambientValues.Add("occasion", "Easter"); + ambientValues.Add("SafeParam", "SafeParamValue"); + + var values = new DispatcherValueCollection(); + values.Add("year", String.Empty); + values.Add("occasion", "Hola"); + + RunTest( + "UrlGeneration1/{controller}.mvc/{action}/{category}/{year}/{occasion}/{SafeParam}", + new DispatcherValueCollection(new { year = 1995, occasion = "Christmas", action = "Play", SafeParam = "SafeParamValue" }), + ambientValues, + values, + "/UrlEncode[[UrlGeneration1]]/UrlEncode[[UrlRouting]]UrlEncode[[.mvc]]/" + + "UrlEncode[[Play]]/UrlEncode[[Photos]]/UrlEncode[[1995]]/UrlEncode[[Hola]]"); + } + + [Fact] + public void GetUrlWithEmptyStringForMiddleParameterShouldUseDefaultValue() + { + var ambientValues = new DispatcherValueCollection(); + ambientValues.Add("Controller", "Test"); + ambientValues.Add("Action", "Fallback"); + ambientValues.Add("param1", "fallback1"); + ambientValues.Add("param2", "fallback2"); + ambientValues.Add("param3", "fallback3"); + + var values = new DispatcherValueCollection(); + values.Add("controller", "subtest"); + values.Add("param1", "b"); + + RunTest( + "{controller}.mvc/{action}/{param1}", + new DispatcherValueCollection(new { action = "Default" }), + ambientValues, + values, + "/UrlEncode[[subtest]]UrlEncode[[.mvc]]/UrlEncode[[Default]]/UrlEncode[[b]]"); + } + + [Fact] + public void GetUrlVerifyEncoding() + { + var values = new DispatcherValueCollection(); + values.Add("controller", "#;?:@&=+$,"); + values.Add("action", "showcategory"); + values.Add("id", 123); + values.Add("so?rt", "de?sc"); + values.Add("maxPrice", 100); + + RunTest( + "{controller}.mvc/{action}/{id}", + new DispatcherValueCollection(new { controller = "Home" }), + new DispatcherValueCollection(new { controller = "home", action = "Index", id = (string)null }), + values, + "/%23;%3F%3A@%26%3D%2B$,.mvc/showcategory/123?so%3Frt=de%3Fsc&maxPrice=100", + UrlEncoder.Default); + } + + [Fact] + public void GetUrlGeneratesQueryStringForNewValuesAndEscapesQueryString() + { + var values = new DispatcherValueCollection(new { controller = "products", action = "showcategory", id = 123, maxPrice = 100 }); + values.Add("so?rt", "de?sc"); + + RunTest( + "{controller}.mvc/{action}/{id}", + new DispatcherValueCollection(new { controller = "Home" }), + new DispatcherValueCollection(new { controller = "home", action = "Index", id = (string)null }), + values, + "/UrlEncode[[products]]UrlEncode[[.mvc]]/UrlEncode[[showcategory]]/UrlEncode[[123]]" + + "?UrlEncode[[so?rt]]=UrlEncode[[de?sc]]&UrlEncode[[maxPrice]]=UrlEncode[[100]]"); + } + + [Fact] + public void GetUrlGeneratesQueryStringForNewValuesButIgnoresNewValuesThatMatchDefaults() + { + RunTest( + "{controller}.mvc/{action}/{id}", + new DispatcherValueCollection(new { controller = "Home", Custom = "customValue" }), + new DispatcherValueCollection(new { controller = "Home", action = "Index", id = (string)null }), + new DispatcherValueCollection( + new + { + controller = "products", + action = "showcategory", + id = 123, + sort = "desc", + maxPrice = 100, + custom = "customValue" + }), + "/UrlEncode[[products]]UrlEncode[[.mvc]]/UrlEncode[[showcategory]]/UrlEncode[[123]]" + + "?UrlEncode[[sort]]=UrlEncode[[desc]]&UrlEncode[[maxPrice]]=UrlEncode[[100]]"); + } + + [Fact] + public void GetVirtualPathEncodesParametersAndLiterals() + { + RunTest( + "bl%og/{controller}/he llo/{action}", + null, + new DispatcherValueCollection(new { controller = "ho%me", action = "li st" }), + new DispatcherValueCollection(), + "/bl%25og/ho%25me/he%20llo/li%20st", + UrlEncoder.Default); + } + + [Fact] + public void GetVirtualDoesNotEncodeLeadingSlashes() + { + RunTest( + "{controller}/{action}", + null, + new DispatcherValueCollection(new { controller = "/home", action = "/my/index" }), + new DispatcherValueCollection(), + "/home/%2Fmy%2Findex", + UrlEncoder.Default); + } + + [Fact] + public void GetUrlWithCatchAllWithValue() + { + RunTest( + "{p1}/{*p2}", + new DispatcherValueCollection(new { id = "defaultid" }), + new DispatcherValueCollection(new { p1 = "v1" }), + new DispatcherValueCollection(new { p2 = "v2a/v2b" }), + "/UrlEncode[[v1]]/UrlEncode[[v2a/v2b]]"); + } + + [Fact] + public void GetUrlWithCatchAllWithEmptyValue() + { + RunTest( + "{p1}/{*p2}", + new DispatcherValueCollection(new { id = "defaultid" }), + new DispatcherValueCollection(new { p1 = "v1" }), + new DispatcherValueCollection(new { p2 = "" }), + "/UrlEncode[[v1]]"); + } + + [Fact] + public void GetUrlWithCatchAllWithNullValue() + { + RunTest( + "{p1}/{*p2}", + new DispatcherValueCollection(new { id = "defaultid" }), + new DispatcherValueCollection(new { p1 = "v1" }), + new DispatcherValueCollection(new { p2 = (string)null }), + "/UrlEncode[[v1]]"); + } + + [Fact] + public void RoutePatternBinder_KeepsExplicitlySuppliedRouteValues_OnFailedRouetMatch() + { + // Arrange + var pattern = "{area?}/{controller=Home}/{action=Index}/{id?}"; + var encoder = new UrlTestEncoder(); + var binder = new RoutePatternBinder( + new UrlTestEncoder(), + new DefaultObjectPoolProvider().Create(new UriBuilderContextPooledObjectPolicy()), + RoutePattern.Parse(pattern), + defaults: null); + var ambientValues = new DispatcherValueCollection(); + var routeValues = new DispatcherValueCollection(new { controller = "Test", action = "Index" }); + + // Act + var valuesResult = binder.GetValues(ambientValues, routeValues); + var result = binder.BindValues(valuesResult.acceptedValues); + + // Assert + Assert.Null(result); + Assert.Equal(2, valuesResult.combinedValues.Count); + object routeValue; + Assert.True(valuesResult.combinedValues.TryGetValue("controller", out routeValue)); + Assert.Equal("Test", routeValue?.ToString()); + Assert.True(valuesResult.combinedValues.TryGetValue("action", out routeValue)); + Assert.Equal("Index", routeValue?.ToString()); + } + +#if ROUTE_COLLECTION + + [Fact] + public void GetUrlShouldValidateOnlyAcceptedParametersAndUserDefaultValuesForInvalidatedParameters() + { + // Arrange + var rd = CreateRouteData(); + rd.Values.Add("Controller", "UrlRouting"); + rd.Values.Add("Name", "MissmatchedValidateParams"); + rd.Values.Add("action", "MissmatchedValidateParameters2"); + rd.Values.Add("ValidateParam1", "special1"); + rd.Values.Add("ValidateParam2", "special2"); + + IRouteCollection rc = new DefaultRouteCollection(); + rc.Add(CreateRoute( + "UrlConstraints/Validation.mvc/Input5/{action}/{ValidateParam1}/{ValidateParam2}", + new DispatcherValueCollection(new { Controller = "UrlRouting", Name = "MissmatchedValidateParams", ValidateParam2 = "valid" }), + new DispatcherValueCollection(new { ValidateParam1 = "valid.*", ValidateParam2 = "valid.*" }))); + + rc.Add(CreateRoute( + "UrlConstraints/Validation.mvc/Input5/{action}/{ValidateParam1}/{ValidateParam2}", + new DispatcherValueCollection(new { Controller = "UrlRouting", Name = "MissmatchedValidateParams" }), + new DispatcherValueCollection(new { ValidateParam1 = "special.*", ValidateParam2 = "special.*" }))); + + var values = CreateDispatcherValueCollection(); + values.Add("Name", "MissmatchedValidateParams"); + values.Add("ValidateParam1", "valid1"); + + // Act + var vpd = rc.GetVirtualPath(GetHttpContext("/app1", "", ""), values); + + // Assert + Assert.NotNull(vpd); + Assert.Equal("/app1/UrlConstraints/Validation.mvc/Input5/MissmatchedValidateParameters2/valid1", vpd.VirtualPath); + } + + [Fact] + public void GetUrlWithRouteThatHasExtensionWithSubsequentDefaultValueIncludesExtensionButNotDefaultValue() + { + // Arrange + var rd = CreateRouteData(); + rd.Values.Add("controller", "Bank"); + rd.Values.Add("action", "MakeDeposit"); + rd.Values.Add("accountId", "7770"); + + IRouteCollection rc = new DefaultRouteCollection(); + rc.Add(CreateRoute( + "{controller}.mvc/Deposit/{accountId}", + new DispatcherValueCollection(new { Action = "DepositView" }))); + + // Note: This route was in the original bug, but it turns out that this behavior is incorrect. With the + // recent fix to Route (in this changelist) this route would have been selected since we have values for + // all three required parameters. + //rc.Add(new Route { + // Url = "{controller}.mvc/{action}/{accountId}", + // RouteHandler = new DummyRouteHandler() + //}); + + // This route should be chosen because the requested action is List. Since the default value of the action + // is List then the Action should not be in the URL. However, the file extension should be included since + // it is considered "safe." + rc.Add(CreateRoute( + "{controller}.mvc/{action}", + new DispatcherValueCollection(new { Action = "List" }))); + + var values = CreateDispatcherValueCollection(); + values.Add("Action", "List"); + + // Act + var vpd = rc.GetVirtualPath(GetHttpContext("/app1", "", ""), values); + + // Assert + Assert.NotNull(vpd); + Assert.Equal("/app1/Bank.mvc", vpd.VirtualPath); + } + + [Fact] + public void GetUrlWithRouteThatHasDifferentControllerCaseShouldStillMatch() + { + // Arrange + var rd = CreateRouteData(); + rd.Values.Add("controller", "Bar"); + rd.Values.Add("action", "bbb"); + rd.Values.Add("id", null); + + IRouteCollection rc = new DefaultRouteCollection(); + rc.Add(CreateRoute("PrettyFooUrl", new DispatcherValueCollection(new { controller = "Foo", action = "aaa", id = (string)null }))); + + rc.Add(CreateRoute("PrettyBarUrl", new DispatcherValueCollection(new { controller = "Bar", action = "bbb", id = (string)null }))); + + rc.Add(CreateRoute("{controller}/{action}/{id}", new DispatcherValueCollection(new { action = "Index", id = (string)null }))); + + var values = CreateDispatcherValueCollection(); + values.Add("Action", "aaa"); + values.Add("Controller", "foo"); + + // Act + var vpd = rc.GetVirtualPath(GetHttpContext("/app1", "", ""), values); + + // Assert + Assert.NotNull(vpd); + Assert.Equal("/app1/PrettyFooUrl", vpd.VirtualPath); + } + + [Fact] + public void GetUrlWithNoChangedValuesShouldProduceSameUrl() + { + // Arrange + var rd = CreateRouteData(); + rd.Values.Add("controller", "Home"); + rd.Values.Add("action", "Index"); + rd.Values.Add("id", null); + + IRouteCollection rc = new DefaultRouteCollection(); + rc.Add(CreateRoute("{controller}.mvc/{action}/{id}", new DispatcherValueCollection(new { action = "Index", id = (string)null }))); + + rc.Add(CreateRoute("{controller}/{action}/{id}", new DispatcherValueCollection(new { action = "Index", id = (string)null }))); + + var values = CreateDispatcherValueCollection(); + values.Add("Action", "Index"); + + // Act + var vpd = rc.GetVirtualPath(GetHttpContext("/app1", "", ""), values); + + // Assert + Assert.NotNull(vpd); + Assert.Equal("/app1/Home.mvc", vpd.VirtualPath); + } + + [Fact] + public void GetUrlAppliesConstraintsRulesToChooseRoute() + { + // Arrange + var rd = CreateRouteData(); + rd.Values.Add("controller", "Home"); + rd.Values.Add("action", "Index"); + rd.Values.Add("id", null); + + IRouteCollection rc = new DefaultRouteCollection(); + rc.Add(CreateRoute( + "foo.mvc/{action}", + new DispatcherValueCollection(new { controller = "Home" }), + new DispatcherValueCollection(new { controller = "Home", action = "Contact", httpMethod = CreateHttpMethodConstraint("get") }))); + + rc.Add(CreateRoute( + "{controller}.mvc/{action}", + new DispatcherValueCollection(new { action = "Index" }), + new DispatcherValueCollection(new { controller = "Home", action = "(Index|About)", httpMethod = CreateHttpMethodConstraint("post") }))); + + var values = CreateDispatcherValueCollection(); + values.Add("Action", "Index"); + + // Act + var vpd = rc.GetVirtualPath(GetHttpContext("/app1", "", ""), values); + + // Assert + Assert.NotNull(vpd); + Assert.Equal("/app1/Home.mvc", vpd.VirtualPath); + } + + [Fact] + public void GetUrlWithValuesThatAreCompletelyDifferentFromTheCurrentRoute() + { + // Arrange + HttpContext context = GetHttpContext("/app", null, null); + IRouteCollection rt = new DefaultRouteCollection(); + rt.Add(CreateRoute("date/{y}/{m}/{d}", null)); + rt.Add(CreateRoute("{controller}/{action}/{id}", null)); + + var rd = CreateRouteData(); + rd.Values.Add("controller", "home"); + rd.Values.Add("action", "dostuff"); + + var values = CreateDispatcherValueCollection(); + values.Add("y", "2007"); + values.Add("m", "08"); + values.Add("d", "12"); + + // Act + var vpd = rt.GetVirtualPath(context, values); + + // Assert + Assert.NotNull(vpd); + Assert.Equal("/app/date/2007/08/12", vpd.VirtualPath); + } + + [Fact] + public void GetUrlWithValuesThatAreCompletelyDifferentFromTheCurrentRouteAsSecondRoute() + { + // Arrange + HttpContext context = GetHttpContext("/app", null, null); + + IRouteCollection rt = new DefaultRouteCollection(); + rt.Add(CreateRoute("{controller}/{action}/{id}")); + rt.Add(CreateRoute("date/{y}/{m}/{d}")); + + var rd = CreateRouteData(); + rd.Values.Add("controller", "home"); + rd.Values.Add("action", "dostuff"); + + var values = CreateDispatcherValueCollection(); + values.Add("y", "2007"); + values.Add("m", "08"); + values.Add("d", "12"); + + // Act + var vpd = rt.GetVirtualPath(context, values); + + // Assert + Assert.NotNull(vpd); + Assert.Equal("/app/date/2007/08/12", vpd.VirtualPath); + } + + [Fact] + public void GetVirtualPathUsesCurrentValuesNotInRouteToMatch() + { + // Arrange + HttpContext context = GetHttpContext("/app", null, null); + TemplateRoute r1 = CreateRoute( + "ParameterMatching.mvc/{Action}/{product}", + new DispatcherValueCollection(new { Controller = "ParameterMatching", product = (string)null }), + null); + + TemplateRoute r2 = CreateRoute( + "{controller}.mvc/{action}", + new DispatcherValueCollection(new { Action = "List" }), + new DispatcherValueCollection(new { Controller = "Action|Bank|Overridden|DerivedFromAction|OverrideInvokeActionAndExecute|InvalidControllerName|Store|HtmlHelpers|(T|t)est|UrlHelpers|Custom|Parent|Child|TempData|ViewFactory|LocatingViews|AccessingDataInViews|ViewOverrides|ViewMasterPage|InlineCompileError|CustomView" }), + null); + + var rd = CreateRouteData(); + rd.Values.Add("controller", "Bank"); + rd.Values.Add("Action", "List"); + var valuesDictionary = CreateDispatcherValueCollection(); + valuesDictionary.Add("action", "AttemptLogin"); + + // Act for first route + var vpd = r1.GetVirtualPath(context, valuesDictionary); + + // Assert + Assert.NotNull(vpd); + Assert.Equal("ParameterMatching.mvc/AttemptLogin", vpd.VirtualPath); + + // Act for second route + vpd = r2.GetVirtualPath(context, valuesDictionary); + + // Assert + Assert.NotNull(vpd); + Assert.Equal("Bank.mvc/AttemptLogin", vpd.VirtualPath); + } + +#endif + +#if DATA_TOKENS + [Fact] + public void GetVirtualPathWithDataTokensCopiesThemFromRouteToVirtualPathData() + { + // Arrange + HttpContext context = GetHttpContext("/app", null, null); + TemplateRoute r = CreateRoute("{controller}/{action}", null, null, new DispatcherValueCollection(new { foo = "bar", qux = "quux" })); + + var rd = CreateRouteData(); + rd.Values.Add("controller", "home"); + rd.Values.Add("action", "index"); + var valuesDictionary = CreateDispatcherValueCollection(); + + // Act + var vpd = r.GetVirtualPath(context, valuesDictionary); + + // Assert + Assert.NotNull(vpd); + Assert.Equal("home/index", vpd.VirtualPath); + Assert.Equal(r, vpd.Route); + Assert.Equal(2, vpd.DataTokens.Count); + Assert.Equal("bar", vpd.DataTokens["foo"]); + Assert.Equal("quux", vpd.DataTokens["qux"]); + } +#endif + +#if ROUTE_FORMAT_HELPER + + [Fact] + public void UrlWithEscapedOpenCloseBraces() + { + RouteFormatHelper("foo/{{p1}}", "foo/{p1}"); + } + + [Fact] + public void UrlWithEscapedOpenBraceAtTheEnd() + { + RouteFormatHelper("bar{{", "bar{"); + } + + [Fact] + public void UrlWithEscapedOpenBraceAtTheBeginning() + { + RouteFormatHelper("{{bar", "{bar"); + } + + [Fact] + public void UrlWithRepeatedEscapedOpenBrace() + { + RouteFormatHelper("foo{{{{bar", "foo{{bar"); + } + + [Fact] + public void UrlWithEscapedCloseBraceAtTheEnd() + { + RouteFormatHelper("bar}}", "bar}"); + } + + [Fact] + public void UrlWithEscapedCloseBraceAtTheBeginning() + { + RouteFormatHelper("}}bar", "}bar"); + } + + [Fact] + public void UrlWithRepeatedEscapedCloseBrace() + { + RouteFormatHelper("foo}}}}bar", "foo}}bar"); + } + + private static void RouteFormatHelper(string routeUrl, string requestUrl) + { + var defaults = new DispatcherValueCollection(new { route = "matched" }); + var r = CreateRoute(routeUrl, defaults, null); + + GetRouteDataHelper(r, requestUrl, defaults); + GetVirtualPathHelper(r, new DispatcherValueCollection(), null, Uri.EscapeUriString(requestUrl)); + } + +#endif + +#if CONSTRAINTS + [Fact] + public void GetVirtualPathWithNonParameterConstraintReturnsUrlWithoutQueryString() + { + // DevDiv Bugs 199612: UrlRouting: UrlGeneration should not append parameter to query string if it is a Constraint parameter and not a Url parameter + RunTest( + "{Controller}.mvc/{action}/{end}", + null, + new DispatcherValueCollection(new { foo = CreateHttpMethodConstraint("GET") }), + new DispatcherValueCollection(), + new DispatcherValueCollection(new { controller = "Orders", action = "Index", end = "end", foo = "GET" }), + "Orders.mvc/Index/end"); + } + + [Fact] + public void GetVirtualPathWithValidCustomConstraints() + { + // Arrange + HttpContext context = GetHttpContext("/app", null, null); + CustomConstraintTemplateRoute r = new CustomConstraintTemplateRoute("{controller}/{action}", null, new DispatcherValueCollection(new { action = 5 })); + + var rd = CreateRouteData(); + rd.Values.Add("controller", "home"); + rd.Values.Add("action", "index"); + + var valuesDictionary = CreateDispatcherValueCollection(); + + // Act + var vpd = r.GetVirtualPath(context, valuesDictionary); + + // Assert + Assert.NotNull(vpd); + Assert.Equal("home/index", vpd.VirtualPath); + Assert.Equal(r, vpd.Route); + Assert.NotNull(r.ConstraintData); + Assert.Equal(5, r.ConstraintData.Constraint); + Assert.Equal("action", r.ConstraintData.ParameterName); + Assert.Equal("index", r.ConstraintData.ParameterValue); + } + + [Fact] + public void GetVirtualPathWithInvalidCustomConstraints() + { + // Arrange + HttpContext context = GetHttpContext("/app", null, null); + CustomConstraintTemplateRoute r = new CustomConstraintTemplateRoute("{controller}/{action}", null, new DispatcherValueCollection(new { action = 5 })); + + var rd = CreateRouteData(); + rd.Values.Add("controller", "home"); + rd.Values.Add("action", "list"); + + var valuesDictionary = CreateDispatcherValueCollection(); + + // Act + var vpd = r.GetVirtualPath(context, valuesDictionary); + + // Assert + Assert.Null(vpd); + Assert.NotNull(r.ConstraintData); + Assert.Equal(5, r.ConstraintData.Constraint); + Assert.Equal("action", r.ConstraintData.ParameterName); + Assert.Equal("list", r.ConstraintData.ParameterValue); + } + +#endif + + private static void RunTest( + string pattern, + DispatcherValueCollection defaults, + DispatcherValueCollection ambientValues, + DispatcherValueCollection values, + string expected, + UrlEncoder encoder = null) + { + // Arrange + encoder = encoder ?? new UrlTestEncoder(); + + var binder = new RoutePatternBinder( + encoder, + new DefaultObjectPoolProvider().Create(new UriBuilderContextPooledObjectPolicy()), + RoutePattern.Parse(pattern), + defaults); + + // Act & Assert + (var acceptedValues, var combinedValues) = binder.GetValues(ambientValues, values); + if (acceptedValues == null) + { + if (expected == null) + { + return; + } + else + { + Assert.NotNull(acceptedValues); + } + } + + var result = binder.BindValues(acceptedValues); + if (expected == null) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + + // We want to chop off the query string and compare that using an unordered comparison + var expectedParts = new PathAndQuery(expected); + var actualParts = new PathAndQuery(result); + + Assert.Equal(expectedParts.Path, actualParts.Path); + + if (expectedParts.Parameters == null) + { + Assert.Null(actualParts.Parameters); + } + else + { + Assert.Equal(expectedParts.Parameters.Count, actualParts.Parameters.Count); + + foreach (var kvp in expectedParts.Parameters) + { + string value; + Assert.True(actualParts.Parameters.TryGetValue(kvp.Key, out value)); + Assert.Equal(kvp.Value, value); + } + } + } + } + + private static void RunTest( + string pattern, + object defaults, + object ambientValues, + object values, + string expected) + { + RunTest( + pattern, + new DispatcherValueCollection(defaults), + new DispatcherValueCollection(ambientValues), + new DispatcherValueCollection(values), + expected); + } + + [Theory] + [InlineData(null, null, true)] + [InlineData("blog", null, false)] + [InlineData(null, "store", false)] + [InlineData("Cool", "cool", true)] + [InlineData("Co0l", "cool", false)] + public void RoutePartsEqualTest(object left, object right, bool expected) + { + // Arrange & Act & Assert + if (expected) + { + Assert.True(RoutePatternBinder.RoutePartsEqual(left, right)); + } + else + { + Assert.False(RoutePatternBinder.RoutePartsEqual(left, right)); + } + } + + private class PathAndQuery + { + public PathAndQuery(string uri) + { + var queryIndex = uri.IndexOf("?", StringComparison.Ordinal); + if (queryIndex == -1) + { + Path = uri; + } + else + { + Path = uri.Substring(0, queryIndex); + + var query = uri.Substring(queryIndex + 1); + Parameters = + query + .Split(new char[] { '&' }, StringSplitOptions.None) + .Select(s => s.Split(new char[] { '=' }, StringSplitOptions.None)) + .ToDictionary(pair => pair[0], pair => pair[1]); + } + } + + public string Path { get; private set; } + + public Dictionary Parameters { get; private set; } + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/RouteCollectionTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/RouteCollectionTest.cs index b5daf39586..64eeb3aa11 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/RouteCollectionTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/RouteCollectionTest.cs @@ -591,6 +591,7 @@ namespace Microsoft.AspNetCore.Routing services.AddSingleton(); services.AddOptions(); services.AddRouting(); + services.AddDispatcher(); if (options != null) { services.Configure(options); @@ -613,6 +614,7 @@ namespace Microsoft.AspNetCore.Routing services.AddSingleton(); services.AddOptions(); services.AddRouting(); + services.AddDispatcher(); if (options != null) { services.Configure(options); diff --git a/test/Microsoft.AspNetCore.Routing.Tests/RouteTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/RouteTest.cs index 7eee101ebb..b660fb58b6 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/RouteTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/RouteTest.cs @@ -1487,6 +1487,7 @@ namespace Microsoft.AspNetCore.Routing services.AddSingleton(NullLoggerFactory.Instance); services.AddSingleton(); services.AddRouting(); + services.AddDispatcher(); var context = new DefaultHttpContext { diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Template/TemplateBinderTests.cs b/test/Microsoft.AspNetCore.Routing.Tests/Template/TemplateBinderTests.cs index 4d71164ce6..2c4f1aa0a6 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Template/TemplateBinderTests.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Template/TemplateBinderTests.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Dispatcher; using Microsoft.AspNetCore.Routing.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.ObjectPool; @@ -119,7 +120,7 @@ namespace Microsoft.AspNetCore.Routing.Template.Tests var encoder = new UrlTestEncoder(); var binder = new TemplateBinder( encoder, - new DefaultObjectPoolProvider().Create(new UriBuilderContextPooledObjectPolicy(encoder)), + new DefaultObjectPoolProvider().Create(new UriBuilderContextPooledObjectPolicy()), TemplateParser.Parse(template), defaults); @@ -269,7 +270,7 @@ namespace Microsoft.AspNetCore.Routing.Template.Tests var encoder = new UrlTestEncoder(); var binder = new TemplateBinder( encoder, - new DefaultObjectPoolProvider().Create(new UriBuilderContextPooledObjectPolicy(encoder)), + new DefaultObjectPoolProvider().Create(new UriBuilderContextPooledObjectPolicy()), TemplateParser.Parse(template), defaults); @@ -699,7 +700,7 @@ namespace Microsoft.AspNetCore.Routing.Template.Tests var encoder = new UrlTestEncoder(); var binder = new TemplateBinder( new UrlTestEncoder(), - new DefaultObjectPoolProvider().Create(new UriBuilderContextPooledObjectPolicy(encoder)), + new DefaultObjectPoolProvider().Create(new UriBuilderContextPooledObjectPolicy()), TemplateParser.Parse(template), defaults: null); var ambientValues = new RouteValueDictionary(); @@ -1131,7 +1132,7 @@ namespace Microsoft.AspNetCore.Routing.Template.Tests var binder = new TemplateBinder( encoder, - new DefaultObjectPoolProvider().Create(new UriBuilderContextPooledObjectPolicy(encoder)), + new DefaultObjectPoolProvider().Create(new UriBuilderContextPooledObjectPolicy()), TemplateParser.Parse(template), defaults); diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Tree/TreeRouteBuilderTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Tree/TreeRouteBuilderTest.cs index 97e166c55e..f2bff2155d 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Tree/TreeRouteBuilderTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Tree/TreeRouteBuilderTest.cs @@ -2,7 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Text.Encodings.Web; -using Microsoft.AspNetCore.Routing.Internal; +using Microsoft.AspNetCore.Dispatcher; using Microsoft.AspNetCore.Routing.Template; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; @@ -244,7 +244,7 @@ namespace Microsoft.AspNetCore.Routing.Tree private static TreeRouteBuilder CreateBuilder() { var objectPoolProvider = new DefaultObjectPoolProvider(); - var objectPolicy = new UriBuilderContextPooledObjectPolicy(UrlEncoder.Default); + var objectPolicy = new UriBuilderContextPooledObjectPolicy(); var objectPool = objectPoolProvider.Create(objectPolicy); var constraintResolver = GetInlineConstraintResolver(); diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Tree/TreeRouterTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Tree/TreeRouterTest.cs index d6c90e9756..8a9da08439 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Tree/TreeRouterTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Tree/TreeRouterTest.cs @@ -6,8 +6,8 @@ using System.Collections.Generic; using System.Linq; using System.Text.Encodings.Web; using System.Threading.Tasks; +using Microsoft.AspNetCore.Dispatcher; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing.Internal; using Microsoft.AspNetCore.Routing.Template; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -24,8 +24,7 @@ namespace Microsoft.AspNetCore.Routing.Tree private static readonly RequestDelegate NullHandler = (c) => Task.FromResult(0); private static UrlEncoder Encoder = UrlTestEncoder.Default; - private static ObjectPool Pool = new DefaultObjectPoolProvider().Create( - new UriBuilderContextPooledObjectPolicy(Encoder)); + private static ObjectPool Pool = new DefaultObjectPoolProvider().Create(new UriBuilderContextPooledObjectPolicy()); [Theory] [InlineData("template/5", "template/{parameter:int}")] @@ -1990,7 +1989,7 @@ namespace Microsoft.AspNetCore.Routing.Tree private static TreeRouteBuilder CreateBuilder() { var objectPoolProvider = new DefaultObjectPoolProvider(); - var objectPolicy = new UriBuilderContextPooledObjectPolicy(UrlEncoder.Default); + var objectPolicy = new UriBuilderContextPooledObjectPolicy(); var objectPool = objectPoolProvider.Create(objectPolicy); var constraintResolver = CreateConstraintResolver();