From 3547341762b34f272af7da6cb22e09c450c79bf6 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 20 Jun 2018 09:02:52 +1200 Subject: [PATCH] Add support for conventional routes with dispatching (#7928) --- .../MvcApplicationBuilderExtensions.cs | 58 +++- .../Builder/MvcEndpointInfo.cs | 105 +++++++ .../Builder/MvcEndpointInfoBuilder.cs | 19 ++ .../MvcEndpointInfoBuilderExtensions.cs | 229 ++++++++++++++ .../Internal/MvcEndpointDataSource.cs | 273 ++++++++++++++--- .../Internal/RouteTemplateWriter.cs | 56 ++++ .../MvcApplicationBuilderExtensionsTest.cs | 20 ++ .../MvcEndpointInfoBuilderExtensionsTest.cs | 282 ++++++++++++++++++ .../Internal/MvcEndpointDataSourceTests.cs | 241 ++++++++++++++- .../Internal/RouteTemplateWriterTests.cs | 33 ++ .../DispatchingTests.cs | 220 +------------- .../RoutingWebSite/StartupWithDispatching.cs | 34 +-- 12 files changed, 1264 insertions(+), 306 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfo.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfoBuilder.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfoBuilderExtensions.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Core/Internal/RouteTemplateWriter.cs create mode 100644 test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcEndpointInfoBuilderExtensionsTest.cs create mode 100644 test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RouteTemplateWriterTests.cs diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcApplicationBuilderExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcApplicationBuilderExtensions.cs index 2dcbcd8fe4..8e0ed3bd2f 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcApplicationBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcApplicationBuilderExtensions.cs @@ -2,6 +2,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; +using System.Linq; using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Routing; @@ -75,15 +77,7 @@ namespace Microsoft.AspNetCore.Builder throw new ArgumentNullException(nameof(configureRoutes)); } - // Verify if AddMvc was done before calling UseMvc - // We use the MvcMarkerService to make sure if all the services were added. - if (app.ApplicationServices.GetService(typeof(MvcMarkerService)) == null) - { - throw new InvalidOperationException(Resources.FormatUnableToFindServices( - nameof(IServiceCollection), - "AddMvc", - "ConfigureServices(...)")); - } + VerifyMvcIsRegistered(app); var middlewarePipelineBuilder = app.ApplicationServices.GetRequiredService(); middlewarePipelineBuilder.ApplicationBuilder = app.New(); @@ -99,5 +93,51 @@ namespace Microsoft.AspNetCore.Builder return app.UseRouter(routes.Build()); } + + public static IApplicationBuilder UseMvcWithEndpoint( + this IApplicationBuilder app, + Action configureRoutes) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + if (configureRoutes == null) + { + throw new ArgumentNullException(nameof(configureRoutes)); + } + + VerifyMvcIsRegistered(app); + + var mvcEndpointDataSource = app.ApplicationServices + .GetRequiredService>() + .OfType() + .First(); + + var constraintResolver = app.ApplicationServices.GetRequiredService(); + + MvcEndpointInfoBuilder routeBuilder = new MvcEndpointInfoBuilder(constraintResolver); + + configureRoutes(routeBuilder); + + mvcEndpointDataSource.ConventionalEndpointInfos.AddRange(routeBuilder.EndpointInfos); + mvcEndpointDataSource.InitializeEndpoints(); + + return app.UseEndpoint(); + } + + private static void VerifyMvcIsRegistered(IApplicationBuilder app) + { + // Verify if AddMvc was done before calling UseMvc + // We use the MvcMarkerService to make sure if all the services were added. + if (app.ApplicationServices.GetService(typeof(MvcMarkerService)) == null) + { + throw new InvalidOperationException(Resources.FormatUnableToFindServices( + nameof(IServiceCollection), + "AddMvc", + "ConfigureServices(...)")); + } + } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfo.cs b/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfo.cs new file mode 100644 index 0000000000..f88f6e4ded --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfo.cs @@ -0,0 +1,105 @@ +// 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.Globalization; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Template; + +namespace Microsoft.AspNetCore.Builder +{ + public class MvcEndpointInfo + { + public MvcEndpointInfo( + string name, + string template, + RouteValueDictionary defaults, + IDictionary constraints, + RouteValueDictionary dataTokens, + IInlineConstraintResolver constraintResolver) + { + Name = name; + Template = template ?? string.Empty; + DataTokens = dataTokens; + + try + { + // Data we parse from the template will be used to fill in the rest of the constraints or + // defaults. The parser will throw for invalid routes. + ParsedTemplate = TemplateParser.Parse(template); + + Constraints = GetConstraints(constraintResolver, ParsedTemplate, constraints); + Defaults = GetDefaults(ParsedTemplate, defaults); + } + catch (Exception exception) + { + throw new RouteCreationException( + string.Format(CultureInfo.CurrentCulture, "An error occurred while creating the route with name '{0}' and template '{1}'.", name, template), exception); + } + } + + public string Name { get; } + public string Template { get; } + public RouteValueDictionary Defaults { get; } + public IDictionary Constraints { get; } + public RouteValueDictionary DataTokens { get; } + internal RouteTemplate ParsedTemplate { get; private set; } + + private static IDictionary GetConstraints( + IInlineConstraintResolver inlineConstraintResolver, + RouteTemplate parsedTemplate, + IDictionary constraints) + { + var constraintBuilder = new RouteConstraintBuilder(inlineConstraintResolver, parsedTemplate.TemplateText); + + if (constraints != null) + { + foreach (var kvp in constraints) + { + constraintBuilder.AddConstraint(kvp.Key, kvp.Value); + } + } + + foreach (var parameter in parsedTemplate.Parameters) + { + if (parameter.IsOptional) + { + constraintBuilder.SetOptional(parameter.Name); + } + + foreach (var inlineConstraint in parameter.InlineConstraints) + { + constraintBuilder.AddResolvedConstraint(parameter.Name, inlineConstraint.Constraint); + } + } + + return constraintBuilder.Build(); + } + + private static RouteValueDictionary GetDefaults( + RouteTemplate parsedTemplate, + RouteValueDictionary defaults) + { + var result = defaults == null ? new RouteValueDictionary() : new RouteValueDictionary(defaults); + + foreach (var parameter in parsedTemplate.Parameters) + { + if (parameter.DefaultValue != null) + { + if (result.ContainsKey(parameter.Name)) + { + throw new InvalidOperationException( + string.Format(CultureInfo.CurrentCulture, "The route parameter '{0}' has both an inline default value and an explicit default value specified. A route parameter cannot contain an inline default value when a default value is specified explicitly. Consider removing one of them.", parameter.Name)); + } + else + { + result.Add(parameter.Name, parameter.DefaultValue); + } + } + } + + return result; + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfoBuilder.cs b/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfoBuilder.cs new file mode 100644 index 0000000000..99000fdd08 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfoBuilder.cs @@ -0,0 +1,19 @@ +// 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.Collections.Generic; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Builder +{ + public class MvcEndpointInfoBuilder + { + public MvcEndpointInfoBuilder(IInlineConstraintResolver constraintResolver) + { + ConstraintResolver = constraintResolver; + } + + public List EndpointInfos { get; } = new List(); + public IInlineConstraintResolver ConstraintResolver { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfoBuilderExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfoBuilderExtensions.cs new file mode 100644 index 0000000000..b52ef3692b --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfoBuilderExtensions.cs @@ -0,0 +1,229 @@ +// 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 Microsoft.AspNetCore.Mvc.Core; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Constraints; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Provides extension methods for to add endpoints. + /// + public static class MvcEndpointInfoBuilderExtensions + { + #region MapEndpoint + /// + /// Adds a endpoint to the with the specified name and template. + /// + /// The to add the endpoint to. + /// The name of the endpoint. + /// The URL pattern of the endpoint. + /// A reference to this instance after the operation has completed. + public static MvcEndpointInfoBuilder MapEndpoint(this MvcEndpointInfoBuilder endpointBuilder, string name, string template) + { + endpointBuilder.MapEndpoint(name, template, null); + return endpointBuilder; + } + + /// + /// Adds a endpoint to the with the specified name, template, and default values. + /// + /// The to add the endpoint to. + /// The name of the endpoint. + /// The URL pattern of the endpoint. + /// + /// An object that contains default values for endpoint parameters. The object's properties represent the names + /// and values of the default values. + /// + /// A reference to this instance after the operation has completed. + public static MvcEndpointInfoBuilder MapEndpoint(this MvcEndpointInfoBuilder endpointBuilder, string name, string template, object defaults) + { + return endpointBuilder.MapEndpoint(name, template, defaults, null); + } + + /// + /// Adds a endpoint to the with the specified name, template, default values, and + /// constraints. + /// + /// The to add the endpoint to. + /// The name of the endpoint. + /// The URL pattern of the endpoint. + /// + /// An object that contains default values for endpoint parameters. The object's properties represent the names + /// and values of the default values. + /// + /// + /// An object that contains constraints for the endpoint. The object's properties represent the names and values + /// of the constraints. + /// + /// A reference to this instance after the operation has completed. + public static MvcEndpointInfoBuilder MapEndpoint(this MvcEndpointInfoBuilder endpointBuilder, string name, string template, object defaults, object constraints) + { + return endpointBuilder.MapEndpoint(name, template, defaults, constraints, null); + } + + /// + /// Adds a endpoint to the with the specified name, template, default values, and + /// data tokens. + /// + /// The to add the endpoint to. + /// The name of the endpoint. + /// The URL pattern of the endpoint. + /// + /// An object that contains default values for endpoint parameters. The object's properties represent the names + /// and values of the default values. + /// + /// + /// An object that contains constraints for the endpoint. The object's properties represent the names and values + /// of the constraints. + /// + /// + /// An object that contains data tokens for the endpoint. The object's properties represent the names and values + /// of the data tokens. + /// + /// A reference to this instance after the operation has completed. + public static MvcEndpointInfoBuilder MapEndpoint(this MvcEndpointInfoBuilder endpointBuilder, string name, string template, object defaults, object constraints, object dataTokens) + { + endpointBuilder.EndpointInfos.Add(new MvcEndpointInfo( + name, + template, + new RouteValueDictionary(defaults), + new RouteValueDictionary(constraints), + new RouteValueDictionary(dataTokens), + endpointBuilder.ConstraintResolver)); + + return endpointBuilder; + } + #endregion + + #region MapAreaEndpoint + /// + /// Adds a endpoint to the with the given MVC area with the specified + /// , and . + /// + /// The to add the endpoint to. + /// The name of the endpoint. + /// The MVC area name. + /// The URL pattern of the endpoint. + /// A reference to this instance after the operation has completed. + public static MvcEndpointInfoBuilder MapAreaEndpoint( + this MvcEndpointInfoBuilder endpointBuilder, + string name, + string areaName, + string template) + { + MapAreaEndpoint(endpointBuilder, name, areaName, template, defaults: null, constraints: null, dataTokens: null); + return endpointBuilder; + } + + /// + /// Adds a endpoint to the with the given MVC area with the specified + /// , , , and + /// . + /// + /// The to add the endpoint to. + /// The name of the endpoint. + /// The MVC area name. + /// The URL pattern of the endpoint. + /// + /// An object that contains default values for endpoint parameters. The object's properties represent the + /// names and values of the default values. + /// + /// A reference to this instance after the operation has completed. + public static MvcEndpointInfoBuilder MapAreaEndpoint( + this MvcEndpointInfoBuilder endpointBuilder, + string name, + string areaName, + string template, + object defaults) + { + MapAreaEndpoint(endpointBuilder, name, areaName, template, defaults, constraints: null, dataTokens: null); + return endpointBuilder; + } + + /// + /// Adds a endpoint to the with the given MVC area with the specified + /// , , , + /// , and . + /// + /// The to add the endpoint to. + /// The name of the endpoint. + /// The MVC area name. + /// The URL pattern of the endpoint. + /// + /// An object that contains default values for endpoint parameters. The object's properties represent the + /// names and values of the default values. + /// + /// + /// An object that contains constraints for the endpoint. The object's properties represent the names and + /// values of the constraints. + /// + /// A reference to this instance after the operation has completed. + public static MvcEndpointInfoBuilder MapAreaEndpoint( + this MvcEndpointInfoBuilder endpointBuilder, + string name, + string areaName, + string template, + object defaults, + object constraints) + { + MapAreaEndpoint(endpointBuilder, name, areaName, template, defaults, constraints, dataTokens: null); + return endpointBuilder; + } + + /// + /// Adds a endpoint to the with the given MVC area with the specified + /// , , , + /// , , and . + /// + /// The to add the endpoint to. + /// The name of the endpoint. + /// The MVC area name. + /// The URL pattern of the endpoint. + /// + /// An object that contains default values for endpoint parameters. The object's properties represent the + /// names and values of the default values. + /// + /// + /// An object that contains constraints for the endpoint. The object's properties represent the names and + /// values of the constraints. + /// + /// + /// An object that contains data tokens for the endpoint. The object's properties represent the names and + /// values of the data tokens. + /// + /// A reference to this instance after the operation has completed. + public static MvcEndpointInfoBuilder MapAreaEndpoint( + this MvcEndpointInfoBuilder endpointBuilder, + string name, + string areaName, + string template, + object defaults, + object constraints, + object dataTokens) + { + if (endpointBuilder == null) + { + throw new ArgumentNullException(nameof(endpointBuilder)); + } + + if (string.IsNullOrEmpty(areaName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(areaName)); + } + + var defaultsDictionary = new RouteValueDictionary(defaults); + defaultsDictionary["area"] = defaultsDictionary["area"] ?? areaName; + + var constraintsDictionary = new RouteValueDictionary(constraints); + constraintsDictionary["area"] = constraintsDictionary["area"] ?? new StringRouteConstraint(areaName); + + endpointBuilder.MapEndpoint(name, template, defaultsDictionary, constraintsDictionary, dataTokens); + return endpointBuilder; + } + #endregion + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs index f91863b862..7bce3996a5 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs @@ -4,11 +4,15 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.EndpointConstraints; using Microsoft.AspNetCore.Routing.Matchers; +using Microsoft.AspNetCore.Routing.Template; using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Mvc.Internal @@ -17,6 +21,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal { private readonly IActionDescriptorCollectionProvider _actions; private readonly MvcEndpointInvokerFactory _invokerFactory; + private readonly IServiceProvider _serviceProvider; private readonly IActionDescriptorChangeProvider[] _actionDescriptorChangeProviders; private readonly List _endpoints; @@ -25,7 +30,8 @@ namespace Microsoft.AspNetCore.Mvc.Internal public MvcEndpointDataSource( IActionDescriptorCollectionProvider actions, MvcEndpointInvokerFactory invokerFactory, - IEnumerable actionDescriptorChangeProviders) + IEnumerable actionDescriptorChangeProviders, + IServiceProvider serviceProvider) { if (actions == null) { @@ -42,70 +48,247 @@ namespace Microsoft.AspNetCore.Mvc.Internal throw new ArgumentNullException(nameof(actionDescriptorChangeProviders)); } + if (serviceProvider == null) + { + throw new ArgumentNullException(nameof(serviceProvider)); + } + _actions = actions; _invokerFactory = invokerFactory; + _serviceProvider = serviceProvider; _actionDescriptorChangeProviders = actionDescriptorChangeProviders.ToArray(); _endpoints = new List(); - - InitializeEndpoints(); + ConventionalEndpointInfos = new List(); } - private void InitializeEndpoints() + public void InitializeEndpoints() { - // note: this code has haxxx. This will only work in some constrained scenarios foreach (var action in _actions.ActionDescriptors.Items) { if (action.AttributeRouteInfo == null) { - // Action does not have an attribute route - continue; - } - - RequestDelegate invokerDelegate = (context) => - { - var values = context.Features.Get().Values; - var routeData = new RouteData(); - foreach (var kvp in values) + // Check each of the conventional templates to see if the action would be reachable + // If the action and template are compatible then create an endpoint with the + // area/controller/action parameter parts replaced with literals + // + // e.g. {controller}/{action} with HomeController.Index and HomeController.Login + // would result in endpoints: + // - Home/Index + // - Home/Login + foreach (var endpointInfo in ConventionalEndpointInfos) { - routeData.Values.Add(kvp.Key, kvp.Value); - } + var actionRouteValues = action.RouteValues; + var endpointTemplateSegments = endpointInfo.ParsedTemplate.Segments; - var actionContext = new ActionContext(context, routeData, action); - - var invoker = _invokerFactory.CreateInvoker(actionContext); - return invoker.InvokeAsync(); - }; - - var metadata = new List(); - - // Add filter descriptors to endpoint metadata - metadata.AddRange(action.FilterDescriptors.OrderBy(f => f, FilterDescriptorOrderComparer.Comparer).Select(f => f.Filter)); - - if (action.ActionConstraints != null && action.ActionConstraints.Count > 0) - { - foreach (var actionConstraint in action.ActionConstraints) - { - if (actionConstraint is HttpMethodActionConstraint httpMethodActionConstraint) + if (MatchRouteValue(action, endpointInfo, "Area") + && MatchRouteValue(action, endpointInfo, "Controller") + && MatchRouteValue(action, endpointInfo, "Action")) { - metadata.Add(new HttpMethodEndpointConstraint(httpMethodActionConstraint.HttpMethods)); + var newEndpointTemplate = TemplateParser.Parse(endpointInfo.Template); + + for (var i = 0; i < newEndpointTemplate.Segments.Count; i++) + { + // Check if the template can be shortened because the remaining parameters are optional + // + // e.g. Matching template {controller=Home}/{action=Index}/{id?} against HomeController.Index + // can resolve to the following endpoints: + // - /Home/Index/{id?} + // - /Home + // - / + if (UseDefaultValuePlusRemainingSegementsOptional(i, action, endpointInfo, newEndpointTemplate)) + { + var subTemplate = RouteTemplateWriter.ToString(newEndpointTemplate.Segments.Take(i)); + + var subEndpoint = CreateEndpoint(action, subTemplate, 0, endpointInfo); + _endpoints.Add(subEndpoint); + } + + var segment = newEndpointTemplate.Segments[i]; + for (var j = 0; j < segment.Parts.Count; j++) + { + var part = segment.Parts[j]; + + if (part.IsParameter && IsMvcParameter(part.Name)) + { + // Replace parameter with literal value + segment.Parts[j] = TemplatePart.CreateLiteral(action.RouteValues[part.Name]); + } + } + } + + var newTemplate = RouteTemplateWriter.ToString(newEndpointTemplate.Segments); + + var endpoint = CreateEndpoint(action, newTemplate, 0, endpointInfo); + _endpoints.Add(endpoint); } } } - - var metadataCollection = new EndpointMetadataCollection(metadata); - - _endpoints.Add(new MatcherEndpoint( - next => invokerDelegate, - action.AttributeRouteInfo.Template, - action.RouteValues, - action.AttributeRouteInfo.Order, - metadataCollection, - action.DisplayName, - new Address(action.AttributeRouteInfo.Name))); + else + { + var endpoint = CreateEndpoint(action, action.AttributeRouteInfo.Template, action.AttributeRouteInfo.Order, action.AttributeRouteInfo); + _endpoints.Add(endpoint); + } } } + private bool IsMvcParameter(string name) + { + if (string.Equals(name, "Area", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Controller", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Action", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + + private bool UseDefaultValuePlusRemainingSegementsOptional(int segmentIndex, ActionDescriptor action, MvcEndpointInfo endpointInfo, RouteTemplate template) + { + // Check whether the remaining segments are all optional and one or more of them is + // for area/controller/action and has a default value + var usedDefaultValue = false; + + for (var i = segmentIndex; i < template.Segments.Count; i++) + { + var segment = template.Segments[i]; + for (var j = 0; j < segment.Parts.Count; j++) + { + var part = segment.Parts[j]; + if (part.IsOptional || part.IsOptionalSeperator || part.IsCatchAll) + { + continue; + } + if (part.IsParameter) + { + if (IsMvcParameter(part.Name)) + { + if (endpointInfo.Defaults[part.Name] is string defaultValue + && action.RouteValues.TryGetValue(part.Name, out var routeValue) + && string.Equals(defaultValue, routeValue, StringComparison.OrdinalIgnoreCase)) + { + usedDefaultValue = true; + continue; + } + } + } + + // Stop because there is a non-optional/non-defaulted trailing value + return false; + } + } + + return usedDefaultValue; + } + + private bool MatchRouteValue(ActionDescriptor action, MvcEndpointInfo endpointInfo, string routeKey) + { + if (!action.RouteValues.TryGetValue(routeKey, out var actionValue) || string.IsNullOrWhiteSpace(actionValue)) + { + // Action does not have a value for this routeKey, most likely because action is not in an area + // Check that the template does not have a parameter for the routeKey + var matchingParameter = endpointInfo.ParsedTemplate.Parameters.SingleOrDefault(p => string.Equals(p.Name, routeKey, StringComparison.OrdinalIgnoreCase)); + if (matchingParameter == null) + { + return true; + } + } + else + { + if (endpointInfo.Defaults != null && string.Equals(actionValue, endpointInfo.Defaults[routeKey] as string, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var matchingParameter = endpointInfo.ParsedTemplate.Parameters.SingleOrDefault(p => string.Equals(p.Name, routeKey, StringComparison.OrdinalIgnoreCase)); + if (matchingParameter != null) + { + // Check that the value matches against constraints on that parameter + // e.g. For {controller:regex((Home|Login))} the controller value must match the regex + // + // REVIEW: This is really ugly + if (endpointInfo.Constraints.TryGetValue(routeKey, out var constraint) + && !constraint.Match(new DefaultHttpContext() { RequestServices = _serviceProvider }, new DummyRouter(), routeKey, new RouteValueDictionary(action.RouteValues), RouteDirection.IncomingRequest)) + { + // Did not match constraint + return false; + } + + return true; + } + } + + return false; + } + + private class DummyRouter : IRouter + { + public VirtualPathData GetVirtualPath(VirtualPathContext context) + { + return null; + } + + public Task RouteAsync(RouteContext context) + { + return Task.CompletedTask; + } + } + + private MatcherEndpoint CreateEndpoint(ActionDescriptor action, string template, int order, object source) + { + RequestDelegate invokerDelegate = (context) => + { + var values = context.Features.Get().Values; + var routeData = new RouteData(); + foreach (var kvp in values) + { + if (kvp.Value != null) + { + routeData.Values.Add(kvp.Key, kvp.Value); + } + } + + var actionContext = new ActionContext(context, routeData, action); + + var invoker = _invokerFactory.CreateInvoker(actionContext); + return invoker.InvokeAsync(); + }; + + var metadata = new List(); + // REVIEW: Used for debugging. Consider removing before release + metadata.Add(source); + metadata.Add(action); + + // Add filter descriptors to endpoint metadata + if (action.FilterDescriptors != null && action.FilterDescriptors.Count > 0) + { + metadata.AddRange(action.FilterDescriptors.OrderBy(f => f, FilterDescriptorOrderComparer.Comparer).Select(f => f.Filter)); + } + + if (action.ActionConstraints != null && action.ActionConstraints.Count > 0) + { + foreach (var actionConstraint in action.ActionConstraints) + { + if (actionConstraint is HttpMethodActionConstraint httpMethodActionConstraint) + { + metadata.Add(new HttpMethodEndpointConstraint(httpMethodActionConstraint.HttpMethods)); + } + } + } + + var metadataCollection = new EndpointMetadataCollection(metadata); + var endpoint = new MatcherEndpoint( + next => invokerDelegate, + template, + action.RouteValues, + order, + metadataCollection, + action.DisplayName, + address: null); + return endpoint; + } + private IChangeToken GetCompositeChangeToken() { if (_actionDescriptorChangeProviders.Length == 1) @@ -136,5 +319,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } public override IReadOnlyList Endpoints => _endpoints; + + public List ConventionalEndpointInfos { get; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/RouteTemplateWriter.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/RouteTemplateWriter.cs new file mode 100644 index 0000000000..b17df21be2 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/RouteTemplateWriter.cs @@ -0,0 +1,56 @@ +// 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.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Routing.Template; + +namespace Microsoft.AspNetCore.Mvc.Internal +{ + internal static class RouteTemplateWriter + { + public static string ToString(IEnumerable routeSegments) + { + return string.Join("/", routeSegments.Select(s => ToString(s))); + } + + private static string ToString(TemplateSegment templateSegment) + { + return string.Join(string.Empty, templateSegment.Parts.Select(p => ToString(p))); + } + + private static string ToString(TemplatePart templatePart) + { + if (templatePart.IsParameter) + { + var partText = "{"; + if (templatePart.IsCatchAll) + { + partText += "*"; + } + partText += templatePart.Name; + foreach (var item in templatePart.InlineConstraints) + { + partText += ":"; + partText += item.Constraint; + } + if (templatePart.DefaultValue != null) + { + partText += "="; + partText += templatePart.DefaultValue; + } + if (templatePart.IsOptional) + { + partText += "?"; + } + partText += "}"; + + return partText; + } + else + { + return templatePart.Text; + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcApplicationBuilderExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcApplicationBuilderExtensionsTest.cs index 8470b7ea25..3b2f75f341 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcApplicationBuilderExtensionsTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcApplicationBuilderExtensionsTest.cs @@ -29,5 +29,25 @@ namespace Microsoft.AspNetCore.Mvc.Core.Builder "in the application startup code.", exception.Message); } + + [Fact] + public void UseMvcWithEndpoint_ThrowsInvalidOperationException_IfMvcMarkerServiceIsNotRegistered() + { + // Arrange + var applicationBuilderMock = new Mock(); + applicationBuilderMock + .Setup(s => s.ApplicationServices) + .Returns(Mock.Of()); + + // Act & Assert + var exception = Assert.Throws( + () => applicationBuilderMock.Object.UseMvcWithEndpoint(rb => { })); + + Assert.Equal( + "Unable to find the required services. Please add all the required services by calling " + + "'IServiceCollection.AddMvc' inside the call to 'ConfigureServices(...)' " + + "in the application startup code.", + exception.Message); + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcEndpointInfoBuilderExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcEndpointInfoBuilderExtensionsTest.cs new file mode 100644 index 0000000000..4a1431793b --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcEndpointInfoBuilderExtensionsTest.cs @@ -0,0 +1,282 @@ +// 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; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Constraints; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Core.Test.Builder +{ + public class MvcEndpointInfoBuilderExtensionsTest + { + #region MapAreaEndpoint + [Fact] + public void MapAreaEndpoint_Simple() + { + // Arrange + var builder = CreateEndpointBuilder(); + + // Act + builder.MapAreaEndpoint(name: null, areaName: "admin", template: "site/Admin/"); + + // Assert + var endpointInfo = Assert.Single(builder.EndpointInfos); + + Assert.Null(endpointInfo.Name); + Assert.Equal("site/Admin/", endpointInfo.Template); + Assert.Collection( + endpointInfo.Constraints.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.IsType(kvp.Value); + }); + Assert.Empty(endpointInfo.DataTokens); + Assert.Collection( + endpointInfo.Defaults.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.Equal("admin", kvp.Value); + }); + } + + [Fact] + public void MapAreaEndpoint_Defaults() + { + // Arrange + var builder = CreateEndpointBuilder(); + + // Act + builder.MapAreaEndpoint( + name: "admin_area", + areaName: "admin", + template: "site/Admin/", + defaults: new { action = "Home" }); + + // Assert + var endpointInfo = Assert.Single(builder.EndpointInfos); + + Assert.Equal("admin_area", endpointInfo.Name); + Assert.Equal("site/Admin/", endpointInfo.Template); + Assert.Collection( + endpointInfo.Constraints.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.IsType(kvp.Value); + }); + Assert.Empty(endpointInfo.DataTokens); + Assert.Collection( + endpointInfo.Defaults.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("action", kvp.Key); + Assert.Equal("Home", kvp.Value); + }, + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.Equal("admin", kvp.Value); + }); + } + + [Fact] + public void MapAreaEndpoint_DefaultsAndConstraints() + { + // Arrange + var builder = CreateEndpointBuilder(); + + // Act + builder.MapAreaEndpoint( + name: "admin_area", + areaName: "admin", + template: "site/Admin/", + defaults: new { action = "Home" }, + constraints: new { id = new IntRouteConstraint() }); + + // Assert + var endpointInfo = Assert.Single(builder.EndpointInfos); + + Assert.Equal("admin_area", endpointInfo.Name); + Assert.Equal("site/Admin/", endpointInfo.Template); + Assert.Collection( + endpointInfo.Constraints.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.IsType(kvp.Value); + }, + kvp => + { + Assert.Equal("id", kvp.Key); + Assert.IsType(kvp.Value); + }); + Assert.Empty(endpointInfo.DataTokens); + Assert.Collection( + endpointInfo.Defaults.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("action", kvp.Key); + Assert.Equal("Home", kvp.Value); + }, + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.Equal("admin", kvp.Value); + }); + } + + [Fact] + public void MapAreaEndpoint_DefaultsConstraintsAndDataTokens() + { + // Arrange + var builder = CreateEndpointBuilder(); + + // Act + builder.MapAreaEndpoint( + name: "admin_area", + areaName: "admin", + template: "site/Admin/", + defaults: new { action = "Home" }, + constraints: new { id = new IntRouteConstraint() }, + dataTokens: new { some_token = "hello" }); + + // Assert + var endpointInfo = Assert.Single(builder.EndpointInfos); + + Assert.Equal("admin_area", endpointInfo.Name); + Assert.Equal("site/Admin/", endpointInfo.Template); + Assert.Collection( + endpointInfo.Constraints.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.IsType(kvp.Value); + }, + kvp => + { + Assert.Equal("id", kvp.Key); + Assert.IsType(kvp.Value); + }); + Assert.Collection( + endpointInfo.DataTokens.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("some_token", kvp.Key); + Assert.Equal("hello", kvp.Value); + }); + Assert.Collection( + endpointInfo.Defaults.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("action", kvp.Key); + Assert.Equal("Home", kvp.Value); + }, + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.Equal("admin", kvp.Value); + }); + } + + [Fact] + public void MapAreaEndpoint_DoesNotReplaceValuesForAreaIfAlreadyPresentInConstraintsOrDefaults() + { + // Arrange + var builder = CreateEndpointBuilder(); + + // Act + builder.MapAreaEndpoint( + name: "admin_area", + areaName: "admin", + template: "site/Admin/", + defaults: new { area = "Home" }, + constraints: new { area = new IntRouteConstraint() }, + dataTokens: new { some_token = "hello" }); + + // Assert + var endpointInfo = Assert.Single(builder.EndpointInfos); + + Assert.Equal("admin_area", endpointInfo.Name); + Assert.Equal("site/Admin/", endpointInfo.Template); + Assert.Collection( + endpointInfo.Constraints.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.IsType(kvp.Value); + }); + Assert.Collection( + endpointInfo.DataTokens.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("some_token", kvp.Key); + Assert.Equal("hello", kvp.Value); + }); + Assert.Collection( + endpointInfo.Defaults.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.Equal("Home", kvp.Value); + }); + } + + [Fact] + public void MapAreaEndpoint_UsesPassedInAreaNameAsIs() + { + // Arrange + var builder = CreateEndpointBuilder(); + var areaName = "user.admin"; + + // Act + builder.MapAreaEndpoint(name: null, areaName: areaName, template: "site/Admin/"); + + // Assert + var endpointInfo = Assert.Single(builder.EndpointInfos); + + Assert.Null(endpointInfo.Name); + Assert.Equal("site/Admin/", endpointInfo.Template); + Assert.Collection( + endpointInfo.Constraints.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.IsType(kvp.Value); + + var values = new RouteValueDictionary(new { area = areaName }); + var match = kvp.Value.Match( + new DefaultHttpContext(), + route: new Mock().Object, + routeKey: kvp.Key, + values: values, + routeDirection: RouteDirection.UrlGeneration); + + Assert.True(match); + }); + Assert.Empty(endpointInfo.DataTokens); + Assert.Collection( + endpointInfo.Defaults.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.Equal(kvp.Value, areaName); + }); + } + #endregion + + private MvcEndpointInfoBuilder CreateEndpointBuilder() + { + var builder = new MvcEndpointInfoBuilder(Mock.Of()); + return builder; + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs index 75eac34e56..f35cc05871 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.Abstractions; @@ -13,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Matchers; +using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Moq; using Xunit; @@ -54,17 +57,16 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test.Internal } }, 0)); + var dataSource = CreateMvcEndpointDataSource(mockDescriptorProvider.Object); + // Act - var dataSource = new MvcEndpointDataSource( - mockDescriptorProvider.Object, - new MvcEndpointInvokerFactory(new ActionInvokerFactory(Array.Empty())), - Array.Empty()); + dataSource.InitializeEndpoints(); // Assert var endpoint = Assert.Single(dataSource.Endpoints); var matcherEndpoint = Assert.IsType(endpoint); - object endpointValue = matcherEndpoint.Values["Name"]; + var endpointValue = matcherEndpoint.Values["Name"]; Assert.Equal(routeValue, endpointValue); Assert.Equal(displayName, matcherEndpoint.DisplayName); @@ -85,8 +87,8 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test.Internal var httpContextMock = new Mock(); httpContextMock.Setup(m => m.Features).Returns(featureCollection); - var mockDescriptorProviderMock = new Mock(); - mockDescriptorProviderMock.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List + var descriptorProviderMock = new Mock(); + descriptorProviderMock.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List { new ActionDescriptor { @@ -109,11 +111,12 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test.Internal var actionInvokerProviderMock = new Mock(); actionInvokerProviderMock.Setup(m => m.CreateInvoker(It.IsAny())).Returns(actionInvokerMock.Object); + var dataSource = CreateMvcEndpointDataSource( + descriptorProviderMock.Object, + new MvcEndpointInvokerFactory(actionInvokerProviderMock.Object)); + // Act - var dataSource = new MvcEndpointDataSource( - mockDescriptorProviderMock.Object, - new MvcEndpointInvokerFactory(actionInvokerProviderMock.Object), - Array.Empty()); + dataSource.InitializeEndpoints(); // Assert var endpoint = Assert.Single(dataSource.Endpoints); @@ -139,8 +142,8 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test.Internal var httpContextMock = new Mock(); httpContextMock.Setup(m => m.Features).Returns(featureCollection); - var mockDescriptorProviderMock = new Mock(); - mockDescriptorProviderMock.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List(), 0)); + var descriptorProviderMock = new Mock(); + descriptorProviderMock.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List(), 0)); var actionInvokerMock = new Mock(); @@ -154,8 +157,8 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test.Internal var changeProvider2Mock = new Mock(); changeProvider2Mock.Setup(m => m.GetChangeToken()).Returns(changeTokenMock.Object); - var dataSource = new MvcEndpointDataSource( - mockDescriptorProviderMock.Object, + var dataSource = CreateMvcEndpointDataSource( + descriptorProviderMock.Object, new MvcEndpointInvokerFactory(actionInvokerProviderMock.Object), new[] { changeProvider1Mock.Object, changeProvider2Mock.Object }); @@ -166,5 +169,211 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test.Internal var compositeChangeToken = Assert.IsType(changeToken); Assert.Equal(2, compositeChangeToken.ChangeTokens.Count); } + + [Theory] + [InlineData("{controller}/{action}/{id?}", new[] { "TestController/TestAction/{id?}" })] + [InlineData("{controller}/{id?}", new string[] { })] + [InlineData("{action}/{id?}", new string[] { })] + [InlineData("{Controller}/{Action}/{id?}", new[] { "TestController/TestAction/{id?}" })] + [InlineData("{CONTROLLER}/{ACTION}/{id?}", new[] { "TestController/TestAction/{id?}" })] + [InlineData("{controller}/{action=TestAction}", new[] { "TestController", "TestController/TestAction" })] + [InlineData("{controller}/{action=TestAction}/{id?}", new[] { "TestController", "TestController/TestAction/{id?}" })] + [InlineData("{controller=TestController}/{action=TestAction}/{id?}", new[] { "", "TestController", "TestController/TestAction/{id?}" })] + [InlineData("{controller}/{action}/{*catchAll}", new[] { "TestController/TestAction/{*catchAll}" })] + [InlineData("{controller}/{action=TestAction}/{*catchAll}", new[] { "TestController", "TestController/TestAction/{*catchAll}" })] + [InlineData("{controller}/{action=TestAction}/{id?}/{*catchAll}", new[] { "TestController", "TestController/TestAction/{id?}/{*catchAll}" })] + //[InlineData("{controller}/{action}.{ext?}", new[] { "TestController/TestAction.{ext?}" })] + //[InlineData("{controller}/{action=TestAction}.{ext?}", new[] { "TestController", "TestController/TestAction.{ext?}" })] + public void InitializeEndpoints_SingleAction(string endpointInfoRoute, string[] finalEndpointTemplates) + { + // Arrange + var mockDescriptorProvider = new Mock(); + mockDescriptorProvider.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List + { + CreateActionDescriptor("TestController", "TestAction") + }, 0)); + + var dataSource = CreateMvcEndpointDataSource(mockDescriptorProvider.Object); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo(string.Empty, endpointInfoRoute)); + + // Act + dataSource.InitializeEndpoints(); + + // Assert + var inspectors = finalEndpointTemplates + .Select(t => new Action(e => Assert.Equal(t, Assert.IsType(e).Template))) + .ToArray(); + + // Assert + Assert.Collection(dataSource.Endpoints, inspectors); + } + + [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?}" })] + public void InitializeEndpoints_AreaSingleAction(string endpointInfoRoute, string[] finalEndpointTemplates) + { + // Arrange + var mockDescriptorProvider = new Mock(); + mockDescriptorProvider.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List + { + CreateActionDescriptor("TestController", "TestAction", "TestArea") + }, 0)); + + var dataSource = CreateMvcEndpointDataSource(mockDescriptorProvider.Object); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo(string.Empty, endpointInfoRoute)); + + // Act + dataSource.InitializeEndpoints(); + + // Assert + var inspectors = finalEndpointTemplates + .Select(t => new Action(e => Assert.Equal(t, Assert.IsType(e).Template))) + .ToArray(); + + // Assert + Assert.Collection(dataSource.Endpoints, inspectors); + } + + [Fact] + public void InitializeEndpoints_SingleAction_WithActionDefault() + { + // Arrange + var mockDescriptorProvider = new Mock(); + mockDescriptorProvider.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List + { + CreateActionDescriptor("TestController", "TestAction") + }, 0)); + + var dataSource = CreateMvcEndpointDataSource(mockDescriptorProvider.Object); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + string.Empty, + "{controller}/{action}", + new RouteValueDictionary(new { action = "TestAction" }))); + + // Act + dataSource.InitializeEndpoints(); + + // Assert + Assert.Collection(dataSource.Endpoints, + (e) => Assert.Equal("TestController", Assert.IsType(e).Template), + (e) => Assert.Equal("TestController/TestAction", Assert.IsType(e).Template)); + } + + [Fact] + public void InitializeEndpoints_MultipleActions_WithActionConstraint() + { + // Arrange + var mockDescriptorProvider = new Mock(); + mockDescriptorProvider.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List + { + CreateActionDescriptor("TestController", "TestAction"), + CreateActionDescriptor("TestController", "TestAction1"), + CreateActionDescriptor("TestController", "TestAction2") + }, 0)); + + var dataSource = CreateMvcEndpointDataSource(mockDescriptorProvider.Object); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + string.Empty, + "{controller}/{action}", + constraints: new RouteValueDictionary(new { action = "(TestAction1|TestAction2)" }))); + + // Act + dataSource.InitializeEndpoints(); + + // Assert + Assert.Collection(dataSource.Endpoints, + (e) => Assert.Equal("TestController/TestAction1", Assert.IsType(e).Template), + (e) => Assert.Equal("TestController/TestAction2", Assert.IsType(e).Template)); + } + + [Theory] + [InlineData("{controller}/{action}", new[] { "TestController1/TestAction1", "TestController1/TestAction2", "TestController1/TestAction3", "TestController2/TestAction1" })] + [InlineData("{controller}/{action:regex((TestAction1|TestAction2))}", new[] { "TestController1/TestAction1", "TestController1/TestAction2", "TestController2/TestAction1" })] + public void InitializeEndpoints_MultipleActions(string endpointInfoRoute, string[] finalEndpointTemplates) + { + // Arrange + var mockDescriptorProvider = new Mock(); + mockDescriptorProvider.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List + { + CreateActionDescriptor("TestController1", "TestAction1"), + CreateActionDescriptor("TestController1", "TestAction2"), + CreateActionDescriptor("TestController1", "TestAction3"), + CreateActionDescriptor("TestController2", "TestAction1") + }, 0)); + + var dataSource = CreateMvcEndpointDataSource(mockDescriptorProvider.Object); + dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo( + string.Empty, + endpointInfoRoute)); + + // Act + dataSource.InitializeEndpoints(); + + var inspectors = finalEndpointTemplates + .Select(t => new Action(e => Assert.Equal(t, Assert.IsType(e).Template))) + .ToArray(); + + // Assert + Assert.Collection(dataSource.Endpoints, inspectors); + } + + private MvcEndpointDataSource CreateMvcEndpointDataSource( + IActionDescriptorCollectionProvider actionDescriptorCollectionProvider = null, + MvcEndpointInvokerFactory mvcEndpointInvokerFactory = null, + IEnumerable actionDescriptorChangeProviders = null) + { + if (actionDescriptorCollectionProvider == null) + { + var mockDescriptorProvider = new Mock(); + mockDescriptorProvider.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List(), 0)); + + actionDescriptorCollectionProvider = mockDescriptorProvider.Object; + } + + var serviceProviderMock = new Mock(); + serviceProviderMock.Setup(m => m.GetService(typeof(IActionDescriptorCollectionProvider))).Returns(actionDescriptorCollectionProvider); + + var dataSource = new MvcEndpointDataSource( + actionDescriptorCollectionProvider, + mvcEndpointInvokerFactory ?? new MvcEndpointInvokerFactory(new ActionInvokerFactory(Array.Empty())), + actionDescriptorChangeProviders ?? Array.Empty(), + serviceProviderMock.Object); + + return dataSource; + } + + private MvcEndpointInfo CreateEndpointInfo( + string name, + string template, + RouteValueDictionary defaults = null, + IDictionary constraints = null, + RouteValueDictionary dataTokens = null) + { + var routeOptions = new RouteOptions(); + var routeOptionsSetup = new MvcCoreRouteOptionsSetup(); + routeOptionsSetup.Configure(routeOptions); + + var constraintResolver = new DefaultInlineConstraintResolver(Options.Create(routeOptions)); + return new MvcEndpointInfo(name, template, defaults, constraints, dataTokens, constraintResolver); + } + + private ActionDescriptor CreateActionDescriptor(string controller, string action, string area = null) + { + return new ActionDescriptor + { + RouteValues = + { + ["controller"] = controller, + ["action"] = action, + ["area"] = area + }, + DisplayName = string.Empty, + }; + } } -} +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RouteTemplateWriterTests.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RouteTemplateWriterTests.cs new file mode 100644 index 0000000000..68fcb544ac --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/RouteTemplateWriterTests.cs @@ -0,0 +1,33 @@ +// 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 Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Routing.Template; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Core.Test.Internal +{ + public class RouteTemplateWriterTests + { + [Theory] + [InlineData(@"")] + [InlineData(@"Literal")] + [InlineData(@"Literal1/Literal2")] + [InlineData(@"{controller}")] + [InlineData(@"{controller}/{action}")] + [InlineData(@"{controller}/{action}/{param:test(\?)?}")] + [InlineData(@"{param:test(\w,\w)=jsd}")] + [InlineData(@"some/url-{p1:int:test(3)=hello}/{p2=abc}/{p3?}")] + [InlineData(@"{param:test(abc:somevalue):name(test1:differentname=default-value}")] + [InlineData(@"api/Blog/{controller}/{action}/{id?}")] + [InlineData(@"{p1}.{p2}.{p3}")] + public void ToString_TemplateRoundtrips(string template) + { + var routeTemplate = TemplateParser.Parse(template); + + var output = RouteTemplateWriter.ToString(routeTemplate.Segments); + + Assert.Equal(template, output); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/DispatchingTests.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/DispatchingTests.cs index 2d5dcb2749..3f8f012c57 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/DispatchingTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/DispatchingTests.cs @@ -12,223 +12,5 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests : base(fixture) { } - - [Fact(Skip = "Conventional routing WIP")] - public override Task ConventionalRoutedController_ActionIsReachable() - { - return Task.CompletedTask; - } - - [Fact(Skip = "Conventional routing WIP")] - public override Task ConventionalRoutedController_ActionIsReachable_WithDefaults() - { - return Task.CompletedTask; - } - - [Fact(Skip = "Conventional routing WIP")] - public override Task ConventionalRoutedController_NonActionIsNotReachable() - { - return Task.CompletedTask; - } - - [Fact(Skip = "Conventional routing WIP")] - public override Task ConventionalRoutedController_InArea_ActionIsReachable() - { - return Task.CompletedTask; - } - - [Fact(Skip = "Conventional routing WIP")] - public override Task ConventionalRoutedController_InArea_ActionBlockedByHttpMethod() - { - return Task.CompletedTask; - } - - [Theory(Skip = "Conventional routing WIP")] - [InlineData("", "/Home/OptionalPath/default")] - [InlineData("CustomPath", "/Home/OptionalPath/CustomPath")] - public override Task ConventionalRoutedController_WithOptionalSegment(string optionalSegment, string expected) - { - return Task.CompletedTask; - } - - [Theory(Skip = "URL generation WIP")] - [InlineData("http://localhost/api/v1/Maps")] - [InlineData("http://localhost/api/v2/Maps")] - public override Task AttributeRoutedAction_MultipleRouteAttributes_WorksWithNameAndOrder(string url) - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_MultipleRouteAttributes_WorksWithOverrideRoutes() - { - return Task.CompletedTask; - } - - [Theory(Skip = "URL generation WIP")] - [InlineData("http://localhost/api/v1/Maps/5", "PUT")] - [InlineData("http://localhost/api/v2/Maps/5", "PUT")] - [InlineData("http://localhost/api/v1/Maps/PartialUpdate/5", "PATCH")] - [InlineData("http://localhost/api/v2/Maps/PartialUpdate/5", "PATCH")] - public override Task AttributeRoutedAction_MultipleRouteAttributes_CombinesWithMultipleHttpAttributes( - string url, - string method) - { - return Task.CompletedTask; - } - - [Theory(Skip = "URL generation WIP")] - [InlineData("http://localhost/Banks/Get/5")] - [InlineData("http://localhost/Bank/Get/5")] - public override Task AttributeRoutedAction_MultipleHttpAttributesAndTokenReplacement(string url) - { - return Task.CompletedTask; - } - - [Theory(Skip = "URL generation WIP")] - [InlineData("PUT", "Bank")] - [InlineData("PATCH", "Bank")] - [InlineData("PUT", "Bank/Update")] - [InlineData("PATCH", "Bank/Update")] - public override Task AttributeRoutedAction_AcceptVerbsAndRouteTemplate_IsReachable(string verb, string path) - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_LinkGeneration_OverrideActionOverridesOrderOnController() - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_LinkGeneration_OrderOnActionOverridesOrderOnController() - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_LinkToSelf() - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_LinkWithAmbientController() - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_LinkToAttributeRoutedController() - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_LinkToConventionalController() - { - return Task.CompletedTask; - } - - [Theory(Skip = "URL generation WIP")] - [InlineData("GET", "Get")] - [InlineData("PUT", "Put")] - public override Task AttributeRoutedAction_LinkWithName_WithNameInheritedFromControllerRoute( - string method, - string actionName) - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_LinkWithName_WithNameOverrridenFromController() - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_Link_WithNonEmptyActionRouteTemplateAndNoActionRouteName() - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_LinkWithName_WithNonEmptyActionRouteTemplateAndActionRouteName() - { - return Task.CompletedTask; - } - - [Fact(Skip = "Conventional routing WIP")] - public override Task ConventionalRoutedAction_LinkToArea() - { - return Task.CompletedTask; - } - - [Fact(Skip = "Conventional routing WIP")] - public override Task ConventionalRoutedAction_InArea_ImplicitLinkToArea() - { - return Task.CompletedTask; - } - - [Fact(Skip = "Conventional routing WIP")] - public override Task ConventionalRoutedAction_InArea_ExplicitLeaveArea() - { - return Task.CompletedTask; - } - - [Fact(Skip = "Conventional routing WIP")] - public override Task ConventionalRoutedAction_InArea_StaysInArea() - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_LinkToArea() - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_InArea_ImplicitLinkToArea() - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_InArea_ExplicitLeaveArea() - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_InArea_StaysInArea_ActionDoesntExist() - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_InArea_LinkToConventionalRoutedActionInArea() - { - return Task.CompletedTask; - } - - [Fact(Skip = "Conventional routing WIP")] - public override Task ConventionalRoutedAction_InArea_LinkToAttributeRoutedActionInArea() - { - return Task.CompletedTask; - } - - [Fact(Skip = "Conventional routing WIP")] - public override Task ConventionalRoutedAction_InArea_LinkToAnotherArea() - { - return Task.CompletedTask; - } - - [Fact(Skip = "URL generation WIP")] - public override Task AttributeRoutedAction_InArea_LinkToAnotherArea() - { - return Task.CompletedTask; - } } -} +} \ No newline at end of file diff --git a/test/WebSites/RoutingWebSite/StartupWithDispatching.cs b/test/WebSites/RoutingWebSite/StartupWithDispatching.cs index 75737e859f..670ec5497d 100644 --- a/test/WebSites/RoutingWebSite/StartupWithDispatching.cs +++ b/test/WebSites/RoutingWebSite/StartupWithDispatching.cs @@ -33,26 +33,24 @@ namespace RoutingWebSite { app.UseDispatcher(); - app.UseEndpoint(); + app.UseMvcWithEndpoint(routes => + { + routes.MapAreaEndpoint( + "flightRoute", + "adminRoute", + "{area:exists}/{controller}/{action}", + new { controller = "Home", action = "Index" }, + new { area = "Travel" }); - //app.UseMvcWithEndpoint(routes => - //{ - // routes.MapAreaEndpoint( - // "flightRoute", - // "adminRoute", - // "{area:exists}/{controller}/{action}", - // new { controller = "Home", action = "Index" }, - // new { area = "Travel" }); + routes.MapEndpoint( + "ActionAsMethod", + "{controller}/{action}", + defaults: new { controller = "Home", action = "Index" }); - // routes.MapEndpoint( - // "ActionAsMethod", - // "{controller}/{action}", - // defaults: new { controller = "Home", action = "Index" }); - - // routes.MapEndpoint( - // "RouteWithOptionalSegment", - // "{controller}/{action}/{path?}"); - //}); + routes.MapEndpoint( + "RouteWithOptionalSegment", + "{controller}/{action}/{path?}"); + }); } } } \ No newline at end of file