diff --git a/build/dependencies.props b/build/dependencies.props index 84f3631ef7..c0129c246e 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -7,7 +7,7 @@ is not otherwise referenced. They avoid unnecessary changes to the Universe build graph or to product dependencies. Do not use these properties elsewhere. --> - + 0.9.9 0.10.13 2.1.1 @@ -16,90 +16,90 @@ 0.43.0 2.1.1.1 2.1.1 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-a-preview3-22cors-16556 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 2.0.0 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-a-preview3-address-scheme-17059 - 2.2.0-a-preview3-address-scheme-17059 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-a-rtm-allow-required-parameters-17081 + 2.2.0-a-rtm-allow-required-parameters-17081 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 5.2.6 15.6.82 2.8.0 2.8.0 - 2.2.0-preview3-35359 + 2.2.0-rtm-35519 1.7.0 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 2.1.0 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 2.0.9 2.1.3 - 2.2.0-preview3-26927-02 - 2.2.0-preview3-35359 - 2.2.0-preview3-35359 + 2.2.0-preview3-27014-02 + 2.2.0-rtm-35519 + 2.2.0-rtm-35519 15.6.1 - 4.7.49 + 4.10.0 2.0.3 1.0.1 11.0.2 diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs index d371d02f79..bf8df39064 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs @@ -222,26 +222,63 @@ namespace Microsoft.AspNetCore.Mvc.Internal bool suppressPathMatching) { var newPathSegments = routePattern.PathSegments.ToList(); + var hasLinkGenerationEndpoint = false; + + // Create a mutable copy + var nonInlineDefaultsCopy = nonInlineDefaults != null + ? new RouteValueDictionary(nonInlineDefaults) + : null; + + var resolvedRouteValues = ResolveActionRouteValues(action, allDefaults); for (var i = 0; i < newPathSegments.Count; i++) { // Check if the pattern can be shortened because the remaining parameters are optional // - // e.g. Matching pattern {controller=Home}/{action=Index}/{id?} against HomeController.Index - // can resolve to the following endpoints: - // - /Home/Index/{id?} - // - /Home + // e.g. Matching pattern {controller=Home}/{action=Index} against HomeController.Index + // can resolve to the following endpoints: (sorted by RouteEndpoint.Order) // - / - if (UseDefaultValuePlusRemainingSegmentsOptional(i, action, allDefaults, newPathSegments)) + // - /Home + // - /Home/Index + if (UseDefaultValuePlusRemainingSegmentsOptional( + i, + action, + resolvedRouteValues, + allDefaults, + ref nonInlineDefaultsCopy, + newPathSegments)) { + // The route pattern has matching default values AND an optional parameter + // For link generation we need to include an endpoint with parameters and default values + // so the link is correctly shortened + // e.g. {controller=Home}/{action=Index}/{id=17} + if (!hasLinkGenerationEndpoint) + { + var ep = CreateEndpoint( + action, + resolvedRouteValues, + name, + GetPattern(ref patternStringBuilder, newPathSegments), + newPathSegments, + nonInlineDefaultsCopy, + routeOrder++, + dataTokens, + suppressLinkGeneration, + true); + endpoints.Add(ep); + + hasLinkGenerationEndpoint = true; + } + var subPathSegments = newPathSegments.Take(i); var subEndpoint = CreateEndpoint( action, + resolvedRouteValues, name, GetPattern(ref patternStringBuilder, subPathSegments), subPathSegments, - nonInlineDefaults, + nonInlineDefaultsCopy, routeOrder++, dataTokens, suppressLinkGeneration, @@ -249,15 +286,83 @@ namespace Microsoft.AspNetCore.Mvc.Internal endpoints.Add(subEndpoint); } - List segmentParts = null; // Initialize only as needed - var segment = newPathSegments[i]; - for (var j = 0; j < segment.Parts.Count; j++) - { - var part = segment.Parts[j]; + UpdatePathSegments(i, action, resolvedRouteValues, routePattern, newPathSegments, ref allParameterPolicies); + } - if (part.IsParameter && - part is RoutePatternParameterPart parameterPart && - action.RouteValues.ContainsKey(parameterPart.Name)) + var finalEndpoint = CreateEndpoint( + action, + resolvedRouteValues, + name, + GetPattern(ref patternStringBuilder, newPathSegments), + newPathSegments, + nonInlineDefaultsCopy, + routeOrder++, + dataTokens, + suppressLinkGeneration, + suppressPathMatching); + endpoints.Add(finalEndpoint); + + return routeOrder; + + string GetPattern(ref StringBuilder sb, IEnumerable segments) + { + if (sb == null) + { + sb = new StringBuilder(); + } + + RoutePatternWriter.WriteString(sb, segments); + var rawPattern = sb.ToString(); + sb.Length = 0; + + return rawPattern; + } + } + + private static IDictionary ResolveActionRouteValues(ActionDescriptor action, IReadOnlyDictionary allDefaults) + { + Dictionary resolvedRequiredValues = null; + + foreach (var kvp in action.RouteValues) + { + // Check whether there is a matching default value with a different case + // e.g. {controller=HOME}/{action} with HomeController.Index will have route values: + // - controller = HOME + // - action = Index + if (allDefaults.TryGetValue(kvp.Key, out var value) && + value is string defaultValue && + !string.Equals(kvp.Value, defaultValue, StringComparison.Ordinal) && + string.Equals(kvp.Value, defaultValue, StringComparison.OrdinalIgnoreCase)) + { + if (resolvedRequiredValues == null) + { + resolvedRequiredValues = new Dictionary(action.RouteValues, StringComparer.OrdinalIgnoreCase); + } + + resolvedRequiredValues[kvp.Key] = defaultValue; + } + } + + return resolvedRequiredValues ?? action.RouteValues; + } + + private void UpdatePathSegments( + int i, + ActionDescriptor action, + IDictionary resolvedRequiredValues, + RoutePattern routePattern, + List newPathSegments, + ref IDictionary> allParameterPolicies) + { + List segmentParts = null; // Initialize only as needed + var segment = newPathSegments[i]; + for (var j = 0; j < segment.Parts.Count; j++) + { + var part = segment.Parts[j]; + + if (part is RoutePatternParameterPart parameterPart) + { + if (resolvedRequiredValues.TryGetValue(parameterPart.Name, out var parameterRouteValue)) { if (segmentParts == null) { @@ -268,9 +373,6 @@ namespace Microsoft.AspNetCore.Mvc.Internal allParameterPolicies = MvcEndpointInfo.BuildParameterPolicies(routePattern.Parameters, _parameterPolicyFactory); } - // Replace parameter with literal value - var parameterRouteValue = action.RouteValues[parameterPart.Name]; - // Route value could be null if it is a "known" route value. // Do not use the null value to de-normalize the route pattern, // instead leave the parameter unchanged. @@ -297,47 +399,21 @@ namespace Microsoft.AspNetCore.Mvc.Internal } } } - - // A parameter part was replaced so replace segment with updated parts - if (segmentParts != null) - { - newPathSegments[i] = RoutePatternFactory.Segment(segmentParts); - } } - var endpoint = CreateEndpoint( - action, - name, - GetPattern(ref patternStringBuilder, newPathSegments), - newPathSegments, - nonInlineDefaults, - routeOrder++, - dataTokens, - suppressLinkGeneration, - suppressPathMatching); - endpoints.Add(endpoint); - - return routeOrder; - - string GetPattern(ref StringBuilder sb, IEnumerable segments) + // A parameter part was replaced so replace segment with updated parts + if (segmentParts != null) { - if (sb == null) - { - sb = new StringBuilder(); - } - - RoutePatternWriter.WriteString(sb, segments); - var rawPattern = sb.ToString(); - sb.Length = 0; - - return rawPattern; + newPathSegments[i] = RoutePatternFactory.Segment(segmentParts); } } private bool UseDefaultValuePlusRemainingSegmentsOptional( int segmentIndex, ActionDescriptor action, + IDictionary resolvedRequiredValues, IReadOnlyDictionary allDefaults, + ref RouteValueDictionary nonInlineDefaults, List pathSegments) { // Check whether the remaining segments are all optional and one or more of them is @@ -352,22 +428,33 @@ namespace Microsoft.AspNetCore.Mvc.Internal var part = segment.Parts[j]; if (part.IsParameter && part is RoutePatternParameterPart parameterPart) { - if (parameterPart.IsOptional || parameterPart.IsCatchAll) + if (allDefaults.TryGetValue(parameterPart.Name, out var v)) { - continue; - } - - if (action.RouteValues.ContainsKey(parameterPart.Name)) - { - if (allDefaults.TryGetValue(parameterPart.Name, out var v) - && v is string defaultValue - && action.RouteValues.TryGetValue(parameterPart.Name, out var routeValue) - && string.Equals(defaultValue, routeValue, StringComparison.OrdinalIgnoreCase)) + if (resolvedRequiredValues.TryGetValue(parameterPart.Name, out var routeValue)) { + if (string.Equals(v as string, routeValue, StringComparison.OrdinalIgnoreCase)) + { + usedDefaultValue = true; + continue; + } + } + else + { + if (nonInlineDefaults == null) + { + nonInlineDefaults = new RouteValueDictionary(); + } + nonInlineDefaults.TryAdd(parameterPart.Name, v); + usedDefaultValue = true; continue; } } + + if (parameterPart.IsOptional || parameterPart.IsCatchAll) + { + continue; + } } else if (part.IsSeparator && part is RoutePatternSeparatorPart separatorPart && separatorPart.Content == ".") @@ -441,6 +528,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal private RouteEndpoint CreateEndpoint( ActionDescriptor action, + IDictionary actionRouteValues, string routeName, string patternRawText, IEnumerable segments, @@ -461,12 +549,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal }; var defaults = new RouteValueDictionary(nonInlineDefaults); - EnsureRequiredValuesInDefaults(action.RouteValues, defaults); + EnsureRequiredValuesInDefaults(actionRouteValues, defaults, segments); var metadataCollection = BuildEndpointMetadata( action, routeName, - new RouteValueDictionary(action.RouteValues), + new RouteValueDictionary(actionRouteValues), dataTokens, suppressLinkGeneration, suppressPathMatching); @@ -554,7 +642,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal return metadataCollection; } - // Ensure required values are a subset of defaults + // Ensure route values are a subset of defaults // Examples: // // Template: {controller}/{action}/{category}/{id?} @@ -568,9 +656,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal // Required values: controller=foo, action=bar // Final constructed pattern: foo/bar/{category}/{id?} // Final defaults: controller=foo, action=bar, category=products - private void EnsureRequiredValuesInDefaults(IDictionary requiredValues, RouteValueDictionary defaults) + private void EnsureRequiredValuesInDefaults( + IDictionary routeValues, + RouteValueDictionary defaults, + IEnumerable segments) { - foreach (var kvp in requiredValues) + foreach (var kvp in routeValues) { if (kvp.Value != null) { diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/RoutePatternWriter.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/RoutePatternWriter.cs index 5beeb6baa6..3f0e0f6445 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/RoutePatternWriter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/RoutePatternWriter.cs @@ -40,6 +40,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal if (parameterPart.IsCatchAll) { sb.Append("*"); + if (!parameterPart.EncodeSlashes) + { + sb.Append("*"); + } } sb.Append(parameterPart.Name); foreach (var item in parameterPart.ParameterPolicies) diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs index 24622f85cd..618b281eba 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs @@ -146,22 +146,29 @@ namespace Microsoft.AspNetCore.Mvc.Internal private static TheoryData GetSingleActionData(bool isConventionalRouting) { - var data = new TheoryData + var data = new TheoryData { - {"{controller}/{action}/{id?}", new[] { "TestController/TestAction/{id?}" }}, - {"{controller}/{id?}", isConventionalRouting ? new string[] { } : new[] { "TestController/{id?}" }}, - {"{action}/{id?}", isConventionalRouting ? new string[] { } : new[] { "TestAction/{id?}" }}, - {"{Controller}/{Action}/{id?}", new[] { "TestController/TestAction/{id?}" }}, - {"{CONTROLLER}/{ACTION}/{id?}", new[] { "TestController/TestAction/{id?}" }}, - {"{controller}/{action=TestAction}", new[] { "TestController", "TestController/TestAction" }}, - {"{controller}/{action=TestAction}/{id?}", new[] { "TestController", "TestController/TestAction/{id?}" }}, - {"{controller=TestController}/{action=TestAction}/{id?}", new[] { "", "TestController", "TestController/TestAction/{id?}" }}, - {"{controller}/{action}/{*catchAll}", new[] { "TestController/TestAction/{*catchAll}" }}, - {"{controller}/{action=TestAction}/{*catchAll}", new[] { "TestController", "TestController/TestAction/{*catchAll}" }}, - {"{controller}/{action=TestAction}/{id?}/{*catchAll}", new[] { "TestController", "TestController/TestAction/{id?}/{*catchAll}" }}, - {"{controller}/{action}.{ext?}", new[] { "TestController/TestAction.{ext?}" }}, - {"{controller}/{action=TestAction}.{ext?}", new[] { "TestController", "TestController/TestAction.{ext?}" }}, - {"{controller:upper-case}/{action=TestAction}.{ext?}", new[] { "TESTCONTROLLER", "TESTCONTROLLER/TestAction.{ext?}" }}, + {"{controller}/{action}/{id?}", null, new[] { "TestController/TestAction/{id?}" }}, + {"{controller}/{id?}", null, isConventionalRouting ? new string[] { } : new[] { "TestController/{id?}" }}, + {"{action}/{id?}", null, isConventionalRouting ? new string[] { } : new[] { "TestAction/{id?}" }}, + {"{Controller}/{Action}/{id?}", null, new[] { "TestController/TestAction/{id?}" }}, + {"{Controller}/{Action}/{id?}/{more?}", null, new[] { "TestController/TestAction/{id?}/{more?}" }}, + {"{CONTROLLER}/{ACTION}/{id?}", null, new[] { "TestController/TestAction/{id?}" }}, + {"{controller}/{action=TestAction}", "TestController/{action=TestAction}", new[] { "TestController", "TestController/TestAction" }}, + {"{controller}/{action=TestAction}/{id?}", "TestController/{action=TestAction}/{id?}", new[] { "TestController", "TestController/TestAction/{id?}" }}, + {"{controller}/{action=TESTACTION}/{id?}", "TestController/{action=TESTACTION}/{id?}", new[] { "TestController", "TestController/TESTACTION/{id?}" }}, + {"{controller}/{action=TestAction}/{id?}/{more}", null, new[] { "TestController/TestAction/{id?}/{more}" }}, + {"{controller=TestController}/{action=TestAction}/{id?}", "{controller=TestController}/{action=TestAction}/{id?}", new[] { "", "TestController", "TestController/TestAction/{id?}" }}, + {"{controller=TestController}/{action=TestAction}/{id?}/{more?}", "{controller=TestController}/{action=TestAction}/{id?}/{more?}", new[] { "", "TestController", "TestController/TestAction/{id?}/{more?}" }}, + {"{controller}/{action}/{*catchAll}", null, new[] { "TestController/TestAction/{*catchAll}" }}, + {"{controller}/{action=TestAction}/{*catchAll}", "TestController/{action=TestAction}/{*catchAll}", new[] { "TestController", "TestController/TestAction/{*catchAll}" }}, + {"{controller}/{action=TestAction}/{id?}/{*catchAll}", "TestController/{action=TestAction}/{id?}/{*catchAll}", new[] { "TestController", "TestController/TestAction/{id?}/{*catchAll}" }}, + {"{controller}/{action=TestAction}/{id?}/{**catchAll}", "TestController/{action=TestAction}/{id?}/{**catchAll}", new[] { "TestController", "TestController/TestAction/{id?}/{**catchAll}" }}, + {"{controller}/{action}.{ext?}", null, new[] { "TestController/TestAction.{ext?}" }}, + {"{controller}/{action=TestAction}.{ext?}", "TestController/{action=TestAction}.{ext?}", new[] { "TestController", "TestController/TestAction.{ext?}" }}, + {"{controller}/{action=TestAction}.{ext?}/{more?}", "TestController/{action=TestAction}.{ext?}/{more?}", new[] { "TestController", "TestController/TestAction.{ext?}/{more?}" }}, + {"{controller}/{action=TestAction}.{ext?}/{more}", null, new[] { "TestController/TestAction.{ext?}/{more}" }}, + {"{controller:upper-case}/{action:upper-case=TestAction}.{ext?}", "TESTCONTROLLER/{action:upper-case=TestAction}.{ext?}", new[] { "TESTCONTROLLER", "TESTCONTROLLER/TESTACTION.{ext?}" }}, }; return data; @@ -169,7 +176,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal [Theory] [MemberData(nameof(GetSingleActionData_Conventional))] - public void Endpoints_Conventional_SingleAction(string endpointInfoRoute, string[] finalEndpointPatterns) + public void Endpoints_Conventional_SingleAction(string endpointInfoRoute, string suppressMatchingTemplate, string[] finalEndpointPatterns) { // Arrange var actionDescriptorCollection = GetActionDescriptorCollection( @@ -178,9 +185,17 @@ namespace Microsoft.AspNetCore.Mvc.Internal dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo(string.Empty, endpointInfoRoute)); // Act - var endpoints = dataSource.Endpoints; + var endpoints = dataSource.Endpoints.ToList(); // Assert + + // Ensure there are no endpoints with duplicate Order values + Assert.DoesNotContain(endpoints.GroupBy(e => Assert.IsType(e).Order), g => g.Count() > 1); + + endpoints = endpoints.OrderBy(e => Assert.IsType(e).Order).ToList(); + + AssertSuppressMatchingTemplate(suppressMatchingTemplate, endpoints); + var inspectors = finalEndpointPatterns .Select(t => new Action(e => Assert.Equal(t, Assert.IsType(e).RoutePattern.RawText))) .ToArray(); @@ -191,7 +206,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal [Theory] [MemberData(nameof(GetSingleActionData_Attribute))] - public void Endpoints_AttributeRouting_SingleAction(string endpointInfoRoute, string[] finalEndpointPatterns) + public void Endpoints_AttributeRouting_SingleAction(string endpointInfoRoute, string suppressMatchingTemplate, string[] finalEndpointPatterns) { // Arrange var actionDescriptorCollection = GetActionDescriptorCollection( @@ -200,7 +215,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); // Act - var endpoints = dataSource.Endpoints; + var endpoints = dataSource.Endpoints.ToList(); + + // Ensure there are no endpoints with duplicate Order values + Assert.DoesNotContain(endpoints.GroupBy(e => Assert.IsType(e).Order), g => g.Count() > 1); + + endpoints = endpoints.OrderBy(e => Assert.IsType(e).Order).ToList(); + + AssertSuppressMatchingTemplate(suppressMatchingTemplate, endpoints); // Assert var inspectors = finalEndpointPatterns @@ -212,14 +234,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal } [Theory] - [InlineData("{area}/{controller}/{action}/{id?}", new[] { "TestArea/TestController/TestAction/{id?}" })] - [InlineData("{controller}/{action}/{id?}", new string[] { })] - [InlineData("{area=TestArea}/{controller}/{action}/{id?}", new[] { "TestArea/TestController/TestAction/{id?}" })] - [InlineData("{area=TestArea}/{controller}/{action=TestAction}/{id?}", new[] { "TestArea/TestController", "TestArea/TestController/TestAction/{id?}" })] - [InlineData("{area=TestArea}/{controller=TestController}/{action=TestAction}/{id?}", new[] { "", "TestArea", "TestArea/TestController", "TestArea/TestController/TestAction/{id?}" })] - [InlineData("{area:exists}/{controller}/{action}/{id?}", new[] { "TestArea/TestController/TestAction/{id?}" })] - [InlineData("{area:exists:upper-case}/{controller}/{action}/{id?}", new[] { "TESTAREA/TestController/TestAction/{id?}" })] - public void Endpoints_AreaSingleAction(string endpointInfoRoute, string[] finalEndpointTemplates) + [InlineData("{area}/{controller}/{action}/{id?}", null, new[] { "TestArea/TestController/TestAction/{id?}" })] + [InlineData("{controller}/{action}/{id?}", null, new string[] { })] + [InlineData("{area=TestArea}/{controller}/{action}/{id?}", null, new[] { "TestArea/TestController/TestAction/{id?}" })] + [InlineData("{area=TestArea}/{controller}/{action=TestAction}/{id?}", "TestArea/TestController/{action=TestAction}/{id?}", new[] { "TestArea/TestController", "TestArea/TestController/TestAction/{id?}"})] + [InlineData("{area=TestArea}/{controller=TestController}/{action=TestAction}/{id?}", "{area=TestArea}/{controller=TestController}/{action=TestAction}/{id?}", new[] { "", "TestArea", "TestArea/TestController", "TestArea/TestController/TestAction/{id?}" })] + [InlineData("{area:exists}/{controller}/{action}/{id?}", null, new[] { "TestArea/TestController/TestAction/{id?}" })] + [InlineData("{area:exists:upper-case}/{controller}/{action}/{id?}", null, new[] { "TESTAREA/TestController/TestAction/{id?}" })] + public void Endpoints_AreaSingleAction(string endpointInfoRoute, string suppressMatchingTemplate, string[] finalEndpointTemplates) { // Arrange var actionDescriptorCollection = GetActionDescriptorCollection( @@ -240,9 +262,17 @@ namespace Microsoft.AspNetCore.Mvc.Internal dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo(string.Empty, endpointInfoRoute, serviceProvider: services.BuildServiceProvider())); // Act - var endpoints = dataSource.Endpoints; + var endpoints = dataSource.Endpoints.ToList(); // Assert + + // Ensure there are no endpoints with duplicate Order values + Assert.DoesNotContain(endpoints.GroupBy(e => Assert.IsType(e).Order), g => g.Count() > 1); + + endpoints = endpoints.OrderBy(e => Assert.IsType(e).Order).ToList(); + + AssertSuppressMatchingTemplate(suppressMatchingTemplate, endpoints); + var inspectors = finalEndpointTemplates .Select(t => new Action(e => Assert.Equal(t, Assert.IsType(e).RoutePattern.RawText))) .ToArray(); @@ -251,6 +281,17 @@ namespace Microsoft.AspNetCore.Mvc.Internal Assert.Collection(endpoints, inspectors); } + private static void AssertSuppressMatchingTemplate(string suppressMatchingTemplate, List endpoints) + { + if (suppressMatchingTemplate != null) + { + var suppressMatchingEndpoint = endpoints.First(); + Assert.True(suppressMatchingEndpoint.Metadata.GetMetadata()?.SuppressMatching); + Assert.Equal(suppressMatchingTemplate, Assert.IsType(suppressMatchingEndpoint).RoutePattern.RawText); + endpoints.Remove(suppressMatchingEndpoint); + } + } + [Fact] public void Endpoints_SingleAction_ConventionalRoute_ContainsParameterWithNullRequiredRouteValue() { @@ -304,6 +345,11 @@ namespace Microsoft.AspNetCore.Mvc.Internal // Assert Assert.Collection(endpoints, + (e) => + { + Assert.Equal("TestController/{action=TestAction}", Assert.IsType(e).RoutePattern.RawText); + Assert.True(e.Metadata.GetMetadata().SuppressMatching); + }, (e) => Assert.Equal("TestController", Assert.IsType(e).RoutePattern.RawText), (e) => Assert.Equal("TestController/TestAction", Assert.IsType(e).RoutePattern.RawText)); } @@ -332,6 +378,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal // Assert Assert.Collection(endpoints1, + (e) => Assert.Equal("TestController/{action=TestAction}", Assert.IsType(e).RoutePattern.RawText), (e) => Assert.Equal("TestController", Assert.IsType(e).RoutePattern.RawText), (e) => Assert.Equal("TestController/TestAction", Assert.IsType(e).RoutePattern.RawText)); Assert.Same(endpoints1, endpoints2); @@ -373,6 +420,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal var endpoints = dataSource.Endpoints; Assert.Collection(endpoints, + (e) => Assert.Equal("TestController/{action=TestAction}", Assert.IsType(e).RoutePattern.RawText), (e) => Assert.Equal("TestController", Assert.IsType(e).RoutePattern.RawText), (e) => Assert.Equal("TestController/TestAction", Assert.IsType(e).RoutePattern.RawText)); @@ -720,10 +768,29 @@ namespace Microsoft.AspNetCore.Mvc.Internal var endpoints = dataSource.Endpoints; // Assert - var endpoint = Assert.Single(endpoints); - var matcherEndpoint = Assert.IsType(endpoint); - Assert.Equal("Foo/Bar/{subscription=general}", matcherEndpoint.RoutePattern.RawText); - AssertIsSubset(expectedDefaults, matcherEndpoint.RoutePattern.Defaults); + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Foo/Bar/{subscription=general}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Foo/Bar", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Foo/Bar/{subscription=general}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(3, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); } [Fact] @@ -780,6 +847,464 @@ namespace Microsoft.AspNetCore.Mvc.Internal AssertIsSubset(expectedDefaults, matcherEndpoint.RoutePattern.Defaults); } + [Fact] + public void Endpoints_ConventionalRoutes_NonDefaultAndDefaultValuesEndingWithOptional_IncludeFullRouteAsHighPriority() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "Home", action = "Index" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller}/{action=Index}/{id?}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Home/{action=Index}/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Home", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Home/Index/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(3, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void Endpoints_ConventionalRoutes_DefaultValuesEndingWithOptional_IncludeFullRouteAsHighPriority() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "Home", action = "Index" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller=Home}/{action=Index}/{id?}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("{controller=Home}/{action=Index}/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Home", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(3, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("Home/Index/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(4, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void Endpoints_ConventionalRoutes_DefaultValues_Shortened() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller=TestController}/{action=TestAction}/{id=17}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("{controller=TestController}/{action=TestAction}/{id=17}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(1, matcherEndpoint.Order); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(2, matcherEndpoint.Order); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(3, matcherEndpoint.Order); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(4, matcherEndpoint.Order); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction/{id=17}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(5, matcherEndpoint.Order); + }); + } + + [Fact] + public void Endpoints_ConventionalRoutes_DefaultValuesAndCatchAll_EndpointInfoDefaultsNotModified() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + + var endpointInfo = CreateEndpointInfo( + name: string.Empty, + defaults: new RouteValueDictionary(), + template: "{controller=TestController}/{action=TestAction}/{id=17}/{**catchAll}"); + dataSource.ConventionalEndpointInfos.Add(endpointInfo); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Empty(endpointInfo.Defaults); + } + + [Fact] + public void Endpoints_ConventionalRoutes_DefaultValuesAndCatchAll_Shortened() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller=TestController}/{action=TestAction}/{id=17}/{**catchAll}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("{controller=TestController}/{action=TestAction}/{id=17}/{**catchAll}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(3, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(4, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction/{id=17}/{**catchAll}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(5, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void Endpoints_ConventionalRoutes_DefaultValuesAndOptional_Shortened() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller=TestController}/{action=TestAction}/{id=17}/{more?}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("{controller=TestController}/{action=TestAction}/{id=17}/{more?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(3, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(4, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction/{id=17}/{more?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("17", matcherEndpoint.RoutePattern.Defaults["id"]); + Assert.Equal(5, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void Endpoints_ConventionalRoutes_OptionalExtension_IncludeFullRouteAsHighPriority() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller}/{action=TestAction}.{ext?}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/{action=TestAction}.{ext?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction.{ext?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(3, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void Endpoints_ConventionalRoutes_MultipleOptionalAndCatchAll_IncludeFullRouteAsHighPriority() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + name: string.Empty, + template: "{controller=TestController}/{action=TestAction}/{id?}/{more?}/{**catchAll}")); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("{controller=TestController}/{action=TestAction}/{id?}/{more?}/{**catchAll}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(3, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TestAction/{id?}/{more?}/{**catchAll}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(4, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void Endpoints_AttributeRoutes_CatchAllWithDefault_IncludeFullRouteAsHighPriority() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + "/TeamName/{*Name=DefaultName}/", + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TeamName/{*Name=DefaultName}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal(0, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TeamName", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("DefaultName", matcherEndpoint.RoutePattern.Defaults["Name"]); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TeamName/{*Name=DefaultName}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("DefaultName", matcherEndpoint.RoutePattern.Defaults["Name"]); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + }); + } + + [Fact] + public void Endpoints_AttributeRoutes_DefaultDifferentCaseFromRouteValue_UseDefaultCase() + { + // Arrange + var actionDescriptorCollection = GetActionDescriptorCollection( + "{controller}/{action=TESTACTION}/{id?}", + new { controller = "TestController", action = "TestAction" }); + var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection); + + // Act + var endpoints = dataSource.Endpoints; + + // Assert + Assert.Collection( + endpoints, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/{action=TESTACTION}/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("TESTACTION", matcherEndpoint.RoutePattern.Defaults["action"]); + Assert.Equal(0, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, true); + + var routeValuesAddress = matcherEndpoint.Metadata.GetMetadata(); + Assert.Equal("TESTACTION", routeValuesAddress.RequiredValues["action"]); + + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("TESTACTION", matcherEndpoint.RoutePattern.Defaults["action"]); + Assert.Equal(1, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + + var routeValuesAddress = matcherEndpoint.Metadata.GetMetadata(); + Assert.Equal("TESTACTION", routeValuesAddress.RequiredValues["action"]); + }, + (ep) => + { + var matcherEndpoint = Assert.IsType(ep); + Assert.Equal("TestController/TESTACTION/{id?}", matcherEndpoint.RoutePattern.RawText); + Assert.Equal("TESTACTION", matcherEndpoint.RoutePattern.Defaults["action"]); + Assert.Equal(2, matcherEndpoint.Order); + AssertMatchingSuppressed(matcherEndpoint, false); + + var routeValuesAddress = matcherEndpoint.Metadata.GetMetadata(); + Assert.Equal("TESTACTION", routeValuesAddress.RequiredValues["action"]); + }); + } + private MvcEndpointDataSource CreateMvcEndpointDataSource( IActionDescriptorCollectionProvider actionDescriptorCollectionProvider = null, MvcEndpointInvokerFactory mvcEndpointInvokerFactory = null) @@ -898,5 +1423,11 @@ namespace Microsoft.AspNetCore.Mvc.Internal Assert.Equal(subsetPair.Value, fullSetPairValue); } } + + private void AssertMatchingSuppressed(Endpoint endpoint, bool suppressed) + { + var isEndpointSuppressed = endpoint.Metadata.GetMetadata()?.SuppressMatching ?? false; + Assert.Equal(suppressed, isEndpointSuppressed); + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTestsBase.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTestsBase.cs index 6ee37de064..5cd65b9bcb 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTestsBase.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RoutingTestsBase.cs @@ -1041,6 +1041,136 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("Departments", result.RouteName); } + [Fact] + public async Task ConventionalRoutedAction_DefaultValues_OptionalParameter_LinkToDefaultValuePath() + { + // Arrange + var url = LinkFrom("http://localhost/DefaultValuesRoute/Optional") + .To(new { }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("DefaultValues", result.Controller); + Assert.Equal("OptionalParameter", result.Action); + + Assert.Equal("/DefaultValuesRoute/Optional", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_DefaultValues_OptionalParameter_LinkToFullPath() + { + // Arrange + var url = LinkFrom("http://localhost/DefaultValuesRoute/Optional") + .To(new { id = "123" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("DefaultValues", result.Controller); + Assert.Equal("OptionalParameter", result.Action); + + Assert.Equal("/DefaultValuesRoute/Optional/DEFAULTVALUES/OPTIONALPARAMETER/123", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_DefaultValues_DefaultParameter_LinkToDefaultValuePath() + { + // Arrange + var url = LinkFrom("http://localhost/DefaultValuesRoute/Default") + .To(new { }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("DefaultValues", result.Controller); + Assert.Equal("DefaultParameter", result.Action); + Assert.Equal("17", result.RouteValues["id"]); + + Assert.Equal("/DefaultValuesRoute/Default", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_DefaultValues_DefaultParameterWithCatchAll_LinkToDefaultValuePath() + { + // Arrange + var url = LinkFrom("http://localhost/DefaultValuesRoute/Default") + .To(new { catchAll = "CatchAll" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("DefaultValues", result.Controller); + Assert.Equal("DefaultParameter", result.Action); + Assert.Equal("17", result.RouteValues["id"]); + + Assert.Equal("/DefaultValuesRoute/Default/DEFAULTVALUES/DEFAULTPARAMETER/17/CatchAll", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_DefaultValues_DefaultParameter_LinkToFullPath() + { + // Arrange + var url = LinkFrom("http://localhost/DefaultValuesRoute/Default") + .To(new { id = "123" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("DefaultValues", result.Controller); + Assert.Equal("DefaultParameter", result.Action); + Assert.Equal("17", result.RouteValues["id"]); + + Assert.Equal("/DefaultValuesRoute/Default/DEFAULTVALUES/DEFAULTPARAMETER/123", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_DefaultValues_DefaultParameterMatches_LinkToShortenedPath() + { + // Arrange + var url = LinkFrom("http://localhost/DefaultValuesRoute/Default/DefaultValues/DefaultParameter/123") + .To(new { id = "17" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("DefaultValues", result.Controller); + Assert.Equal("DefaultParameter", result.Action); + Assert.Equal("123", result.RouteValues["id"]); + + Assert.Equal("/DefaultValuesRoute/Default", result.Link); + } + [Fact] public virtual async Task ConventionalRoutedAction_LinkToArea() { diff --git a/test/WebSites/RoutingWebSite/Controllers/DefaultValuesController.cs b/test/WebSites/RoutingWebSite/Controllers/DefaultValuesController.cs new file mode 100644 index 0000000000..f28c5c14c5 --- /dev/null +++ b/test/WebSites/RoutingWebSite/Controllers/DefaultValuesController.cs @@ -0,0 +1,34 @@ +// 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.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace RoutingWebSite +{ + public class DefaultValuesController : Controller + { + private readonly TestResponseGenerator _generator; + + public DefaultValuesController(TestResponseGenerator generator) + { + _generator = generator; + } + + public IActionResult DefaultParameter(string id) + { + return _generator.Generate(id == null + ? "/DefaultValuesRoute/DefaultValues" + : "/DefaultValuesRoute/DefaultValues/DefaultParameter/Index/" + id); + } + + public IActionResult OptionalParameter(string id) + { + return _generator.Generate(id == "17" + ? "/DefaultValuesRoute/DefaultValues" + : "/DefaultValuesRoute/DefaultValues/OptionalParameter/Index/" + id); + } + } +} \ No newline at end of file diff --git a/test/WebSites/RoutingWebSite/Startup.cs b/test/WebSites/RoutingWebSite/Startup.cs index 03b1fa2ba2..44bc08a25f 100644 --- a/test/WebSites/RoutingWebSite/Startup.cs +++ b/test/WebSites/RoutingWebSite/Startup.cs @@ -62,6 +62,18 @@ namespace RoutingWebSite defaults: null, constraints: new { controller = "ConventionalTransformer" }); + routes.MapRoute( + "DefaultValuesRoute_OptionalParameter", + "DefaultValuesRoute/Optional/{controller=DEFAULTVALUES}/{action=OPTIONALPARAMETER}/{id?}/{**catchAll}", + defaults: null, + constraints: new { controller = "DefaultValues", action = "OptionalParameter" }); + + routes.MapRoute( + "DefaultValuesRoute_DefaultParameter", + "DefaultValuesRoute/Default/{controller=DEFAULTVALUES}/{action=DEFAULTPARAMETER}/{id=17}/{**catchAll}", + defaults: null, + constraints: new { controller = "DefaultValues", action = "DefaultParameter" }); + routes.MapAreaRoute( "flightRoute", "adminRoute", diff --git a/test/WebSites/RoutingWebSite/StartupWith21Compat.cs b/test/WebSites/RoutingWebSite/StartupWith21Compat.cs index 473653df24..9b78b66d32 100644 --- a/test/WebSites/RoutingWebSite/StartupWith21Compat.cs +++ b/test/WebSites/RoutingWebSite/StartupWith21Compat.cs @@ -1,14 +1,11 @@ // 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.Linq; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -48,7 +45,7 @@ namespace RoutingWebSite new ControllerToRemove { ControllerType = typeof(PageRouteController), - Actions = new [] { nameof(PageRouteController.AttributeRoute) } + Actions = new[] { nameof(PageRouteController.AttributeRoute) } }); services.TryAddEnumerable(ServiceDescriptor.Singleton(actionDescriptorProvider)); } @@ -64,6 +61,18 @@ namespace RoutingWebSite constraints: new { controller = "DataTokens" }, dataTokens: new { hasDataTokens = true }); + routes.MapRoute( + "DefaultValuesRoute_OptionalParameter", + "DefaultValuesRoute/Optional/{controller=DEFAULTVALUES}/{action=OPTIONALPARAMETER}/{id?}/{**catchAll}", + defaults: null, + constraints: new { controller = "DefaultValues", action = "OptionalParameter" }); + + routes.MapRoute( + "DefaultValuesRoute_DefaultParameter", + "DefaultValuesRoute/Default/{controller=DEFAULTVALUES}/{action=DEFAULTPARAMETER}/{id=17}/{**catchAll}", + defaults: null, + constraints: new { controller = "DefaultValues", action = "DefaultParameter" }); + routes.MapAreaRoute( "flightRoute", "adminRoute",