From e396f1b45135ff66ed6a84c96522d942c62107dc Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Tue, 10 Jun 2014 14:16:13 -0700 Subject: [PATCH] Adding attribute routing --- Mvc.sln | 11 ++ samples/MvcSample.Web/SimpleRest.cs | 10 +- .../ActionDescriptor.cs | 7 +- .../HttpGetAttribute.cs | 26 ++- .../Microsoft.AspNet.Mvc.Core.kproj | 13 +- .../ReflectedActionDescriptorProvider.cs | 69 +++++++- .../ReflectedActionModel.cs | 9 ++ .../ReflectedControllerModel.cs | 9 ++ .../RouteAttribute.cs | 27 ++++ .../Routing/AttributeRoute.cs | 55 +++++++ .../Routing/AttributeRouteEntry.cs | 24 +++ .../Routing/AttributeRoutePrecedence.cs | 71 +++++++++ .../Routing/AttributeRouteTemplate.cs | 49 ++++++ .../Routing/AttributeRouting.cs | 97 ++++++++++++ .../Routing/IRouteTemplateProvider.cs | 16 ++ src/Microsoft.AspNet.Mvc/BuilderExtensions.cs | 5 + .../ActionAttributeTests.cs | 5 +- .../ActionSelectionConventionTests.cs | 6 +- .../Microsoft.AspNet.Mvc.Core.Test.kproj | 2 + .../ReflectedActionDescriptorProviderTests.cs | 15 +- .../Routing/AttributeRoutePrecedenceTests.cs | 75 +++++++++ .../Routing/AttributeRouteTemplateTests.cs | 59 +++++++ ...Microsoft.AspNet.Mvc.FunctionalTests.kproj | 2 +- .../RoutingTests.cs | 149 ++++++++++++++++++ .../Controllers/BlogController.cs | 29 ++++ .../Controllers/EmployeeController.cs | 35 ++++ .../Controllers/HomeController.cs | 1 + .../Controllers/StoreController.cs | 31 ++++ .../RoutingWebSite/RoutingWebSite.kproj | 3 + 29 files changed, 892 insertions(+), 18 deletions(-) create mode 100644 src/Microsoft.AspNet.Mvc.Core/RouteAttribute.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRoute.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteEntry.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRoutePrecedence.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteTemplate.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouting.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/Routing/IRouteTemplateProvider.cs create mode 100644 test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRoutePrecedenceTests.cs create mode 100644 test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRouteTemplateTests.cs create mode 100644 test/WebSites/RoutingWebSite/Controllers/BlogController.cs create mode 100644 test/WebSites/RoutingWebSite/Controllers/EmployeeController.cs create mode 100644 test/WebSites/RoutingWebSite/Controllers/StoreController.cs diff --git a/Mvc.sln b/Mvc.sln index 502a66526f..a4a515f8c4 100644 --- a/Mvc.sln +++ b/Mvc.sln @@ -237,6 +237,16 @@ Global {42CDBF4A-E238-4C0F-A416-44588363EB4C}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU {42CDBF4A-E238-4C0F-A416-44588363EB4C}.Release|Mixed Platforms.Build.0 = Release|Any CPU {42CDBF4A-E238-4C0F-A416-44588363EB4C}.Release|x86.ActiveCfg = Release|Any CPU + {5C34562F-2861-4CD6-AF02-462A9A8D76EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C34562F-2861-4CD6-AF02-462A9A8D76EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C34562F-2861-4CD6-AF02-462A9A8D76EE}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {5C34562F-2861-4CD6-AF02-462A9A8D76EE}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {5C34562F-2861-4CD6-AF02-462A9A8D76EE}.Debug|x86.ActiveCfg = Debug|Any CPU + {5C34562F-2861-4CD6-AF02-462A9A8D76EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C34562F-2861-4CD6-AF02-462A9A8D76EE}.Release|Any CPU.Build.0 = Release|Any CPU + {5C34562F-2861-4CD6-AF02-462A9A8D76EE}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {5C34562F-2861-4CD6-AF02-462A9A8D76EE}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {5C34562F-2861-4CD6-AF02-462A9A8D76EE}.Release|x86.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -261,5 +271,6 @@ Global {07C0E921-FCBB-458C-AC11-3D01CE68B16B} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {680D75ED-601F-4D86-B01B-1072D0C31B8C} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {42CDBF4A-E238-4C0F-A416-44588363EB4C} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} + {5C34562F-2861-4CD6-AF02-462A9A8D76EE} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} EndGlobalSection EndGlobal diff --git a/samples/MvcSample.Web/SimpleRest.cs b/samples/MvcSample.Web/SimpleRest.cs index fa33fb0e31..643323b7b2 100644 --- a/samples/MvcSample.Web/SimpleRest.cs +++ b/samples/MvcSample.Web/SimpleRest.cs @@ -2,11 +2,19 @@ using Microsoft.AspNet.Mvc; namespace MvcSample.Web { + [Route("api/REST")] public class SimpleRest : Controller { - public string Get() + [HttpGet] + public string ThisIsAGetMethod() { return "Get method"; } + + [HttpGet("OtherThing")] + public string GetOtherThing() + { + return "Get other thing"; + } } } diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionDescriptor.cs b/src/Microsoft.AspNet.Mvc.Core/ActionDescriptor.cs index 0f910cc103..00db2d6948 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ActionDescriptor.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ActionDescriptor.cs @@ -2,7 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Collections.Generic; -using System.Diagnostics; +using Microsoft.AspNet.Mvc.Routing; namespace Microsoft.AspNet.Mvc { @@ -12,6 +12,11 @@ namespace Microsoft.AspNet.Mvc public List RouteConstraints { get; set; } + /// + /// The route template May be null if the action has no attribute routes. + /// + public string RouteTemplate { get; set; } + public List MethodConstraints { get; set; } public List DynamicConstraints { get; set; } diff --git a/src/Microsoft.AspNet.Mvc.Core/HttpGetAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/HttpGetAttribute.cs index 2acb725e38..7b37883259 100644 --- a/src/Microsoft.AspNet.Mvc.Core/HttpGetAttribute.cs +++ b/src/Microsoft.AspNet.Mvc.Core/HttpGetAttribute.cs @@ -3,17 +3,41 @@ using System; using System.Collections.Generic; +using Microsoft.AspNet.Mvc.Routing; namespace Microsoft.AspNet.Mvc { + /// + /// Identifies an action that only supports the HTTP GET method. + /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] - public sealed class HttpGetAttribute : Attribute, IActionHttpMethodProvider + public sealed class HttpGetAttribute : Attribute, IActionHttpMethodProvider, IRouteTemplateProvider { private static readonly IEnumerable _supportedMethods = new string[] { "GET" }; + /// + /// Creates a new . + /// + public HttpGetAttribute() + { + } + + /// + /// Creates a new with the given route template. + /// + /// The route template. May not be null. + public HttpGetAttribute([NotNull] string template) + { + Template = template; + } + + /// public IEnumerable HttpMethods { get { return _supportedMethods; } } + + /// + public string Template { get; private set; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj b/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj index fbaec86171..b2a02ec1b1 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj +++ b/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj @@ -27,6 +27,8 @@ + + @@ -74,7 +76,6 @@ - @@ -152,11 +153,10 @@ - - + @@ -203,10 +203,17 @@ + + + + + + + diff --git a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs index 291d742b17..27c211f16e 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs @@ -8,6 +8,9 @@ using System.Linq; using System.Reflection; #endif using Microsoft.AspNet.Mvc.ReflectedModelBuilder; +using Microsoft.AspNet.Mvc.Routing; +using Microsoft.AspNet.Routing; +using Microsoft.AspNet.Routing.Template; using Microsoft.Framework.OptionsModel; namespace Microsoft.AspNet.Mvc @@ -20,16 +23,19 @@ namespace Microsoft.AspNet.Mvc private readonly IActionDiscoveryConventions _conventions; private readonly IEnumerable _globalFilters; private readonly IEnumerable _modelConventions; + private readonly IInlineConstraintResolver _constraintResolver; public ReflectedActionDescriptorProvider(IControllerAssemblyProvider controllerAssemblyProvider, IActionDiscoveryConventions conventions, IEnumerable globalFilters, - IOptionsAccessor optionsAccessor) + IOptionsAccessor optionsAccessor, + IInlineConstraintResolver constraintResolver) { _controllerAssemblyProvider = controllerAssemblyProvider; _conventions = conventions; _globalFilters = globalFilters ?? Enumerable.Empty(); _modelConventions = optionsAccessor.Options.ApplicationModelConventions; + _constraintResolver = constraintResolver; } public int Order @@ -106,6 +112,8 @@ namespace Microsoft.AspNet.Mvc public List Build(ReflectedApplicationModel model) { + var routeGroupsByTemplate = GetRouteGroupsByTemplate(model); + var actions = new List(); var removalConstraints = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -188,6 +196,45 @@ namespace Microsoft.AspNet.Mvc } } + if (routeGroupsByTemplate.Any()) + { + var templateText = AttributeRouteTemplate.Combine( + controller.RouteTemplate, + action.RouteTemplate); + + if (templateText == null) + { + // A conventional routed action can't match any route group. + actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( + AttributeRouting.RouteGroupKey, + RouteKeyHandling.DenyKey)); + } + else + { + // An attribute routed action will ignore conventional routed constraints. + actionDescriptor.RouteConstraints.Clear(); + + // TODO #738 - this currently has parity with what we did in MVC5 for the action + // route values. This needs to be reconsidered as part of #738. + var template = TemplateParser.Parse(templateText, _constraintResolver); + if (template.Parameters.Any( + p => p.IsParameter && + string.Equals(p.Name, "action", StringComparison.OrdinalIgnoreCase))) + { + actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( + "action", + action.ActionName)); + } + + var routeGroup = routeGroupsByTemplate[templateText]; + actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( + AttributeRouting.RouteGroupKey, + routeGroup)); + + actionDescriptor.RouteTemplate = templateText; + } + } + actionDescriptor.FilterDescriptors = action.Filters.Select(f => new FilterDescriptor(f, FilterScope.Action)) .Concat(controller.Filters.Select(f => new FilterDescriptor(f, FilterScope.Controller))) @@ -214,5 +261,25 @@ namespace Microsoft.AspNet.Mvc return actions; } + + // Groups the set of all attribute routing templates and returns mapping of [template -> group]. + private static Dictionary GetRouteGroupsByTemplate(ReflectedApplicationModel model) + { + var groupsByTemplate = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var controller in model.Controllers) + { + foreach (var action in controller.Actions) + { + var template = AttributeRouteTemplate.Combine(controller.RouteTemplate, action.RouteTemplate); + if (template != null && !groupsByTemplate.ContainsKey(template)) + { + groupsByTemplate.Add(template, "__route__" + template); + } + } + } + + return groupsByTemplate; + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedActionModel.cs b/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedActionModel.cs index 6e99becdf5..fc3b82f9c9 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedActionModel.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedActionModel.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using Microsoft.AspNet.Mvc.Routing; namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder { @@ -19,6 +20,12 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder Filters = Attributes.OfType().ToList(); + var routeTemplateAttribute = Attributes.OfType().FirstOrDefault(); + if (routeTemplateAttribute != null) + { + RouteTemplate = routeTemplateAttribute.Template; + } + HttpMethods = new List(); Parameters = new List(); } @@ -36,5 +43,7 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder public bool IsActionNameMatchRequired { get; set; } public List Parameters { get; private set; } + + public string RouteTemplate { get; set; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedControllerModel.cs b/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedControllerModel.cs index c06972f175..b0398a2f6e 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedControllerModel.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ReflectedModelBuilder/ReflectedControllerModel.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using Microsoft.AspNet.Mvc.Routing; namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder { @@ -23,6 +24,12 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder Filters = Attributes.OfType().ToList(); RouteConstraints = Attributes.OfType().ToList(); + var routeTemplateAttribute = Attributes.OfType().FirstOrDefault(); + if (routeTemplateAttribute != null) + { + RouteTemplate = routeTemplateAttribute.Template; + } + ControllerName = controllerType.Name.EndsWith("Controller", StringComparison.Ordinal) ? controllerType.Name.Substring(0, controllerType.Name.Length - "Controller".Length) : controllerType.Name; @@ -39,5 +46,7 @@ namespace Microsoft.AspNet.Mvc.ReflectedModelBuilder public List Filters { get; private set; } public List RouteConstraints { get; private set; } + + public string RouteTemplate { get; set; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/RouteAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/RouteAttribute.cs new file mode 100644 index 0000000000..b27fd64df4 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/RouteAttribute.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNet.Mvc.Routing; + +namespace Microsoft.AspNet.Mvc +{ + /// + /// Specifies an attribute route on a controller. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public class RouteAttribute : Attribute, IRouteTemplateProvider + { + /// + /// Creates a new with the given route template. + /// + /// The route template. May not be null. + public RouteAttribute([NotNull] string template) + { + Template = template; + } + + /// + public string Template { get; private set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRoute.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRoute.cs new file mode 100644 index 0000000000..d1f2f890a4 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRoute.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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 System.Threading.Tasks; +using Microsoft.AspNet.Routing; +using Microsoft.AspNet.Routing.Template; + +namespace Microsoft.AspNet.Mvc.Routing +{ + /// + /// An implementation for attribute routing. + /// + public class AttributeRoute : IRouter + { + private readonly IRouter _next; + private readonly TemplateRoute[] _routes; + + /// + /// Creates a new . + /// + /// The next router. Invoked when a route entry matches. + /// The set of route entries. + public AttributeRoute([NotNull] IRouter next, [NotNull] IEnumerable entries) + { + _next = next; + + // FOR RIGHT NOW - this is just an array of regular template routes. We'll follow up by implementing + // a good data-structure here. + _routes = entries.OrderBy(e => e.Precedence).Select(e => e.Route).ToArray(); + } + + /// + public async Task RouteAsync([NotNull] RouteContext context) + { + foreach (var route in _routes) + { + await route.RouteAsync(context); + if (context.IsHandled) + { + return; + } + } + } + + /// + public string GetVirtualPath([NotNull] VirtualPathContext context) + { + // Not implemented right now, but we don't want to throw here and block other routes from generating + // a link. + return null; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteEntry.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteEntry.cs new file mode 100644 index 0000000000..f82dc084a2 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteEntry.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Routing.Template; + +namespace Microsoft.AspNet.Mvc.Routing +{ + /// + /// Used to build an . Represents an individual route that will be aggregated + /// into the . + /// + public class AttributeRouteEntry + { + /// + /// The precedence of the template. + /// + public decimal Precedence { get; set; } + + /// + /// The . + /// + public TemplateRoute Route { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRoutePrecedence.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRoutePrecedence.cs new file mode 100644 index 0000000000..833356001d --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRoutePrecedence.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.Diagnostics; +using System.Diagnostics.Contracts; +using Microsoft.AspNet.Routing.Template; + +namespace Microsoft.AspNet.Mvc.Routing +{ + /// + /// Computes precedence for an attribute route template. + /// + public static class AttributeRoutePrecedence + { + public static decimal Compute(Template template) + { + // Each precedence digit corresponds to one decimal place. For example, 3 segments with precedences 2, 1, + // and 4 results in a combined precedence of 2.14 (decimal). + var precedence = 0m; + + for (var i = 0; i < template.Segments.Count; i++) + { + var segment = template.Segments[i]; + + var digit = ComputeDigit(segment); + Contract.Assert(digit >= 0 && digit < 10); + + precedence += Decimal.Divide(digit, (decimal)Math.Pow(10, i)); + } + + return precedence; + } + + // Segments have the following order: + // 1 - Literal segments + // 2 - Constrained parameter segments / Multi-part segments + // 3 - Unconstrained parameter segments + // 4 - Constrained wildcard parameter segments + // 5 - Unconstrained wildcard parameter segments + private static int ComputeDigit(TemplateSegment segment) + { + if (segment.Parts.Count > 1) + { + // Multi-part segments should appear after literal segments but before parameter segments + return 2; + } + + var part = segment.Parts[0]; + // Literal segments always go first + if (part.IsLiteral) + { + return 1; + } + else + { + Debug.Assert(part.IsParameter); + var digit = part.IsCatchAll ? 5 : 3; + + // If there is a route constraint for the parameter, reduce order by 1 + // Constrained parameters end up with order 2, Constrained catch alls end up with order 4 + if (part.InlineConstraint != null) + { + digit--; + } + + return digit; + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteTemplate.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteTemplate.cs new file mode 100644 index 0000000000..599fd104a4 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteTemplate.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Mvc.Routing +{ + /// + /// Functionality supporting route templates for attribute routes. + /// + public static class AttributeRouteTemplate + { + /// + /// Combines attribute routing templates. + /// + /// The left template. + /// The right template. + /// A combined template. + public static string Combine(string left, string right) + { + if (left == null && right == null) + { + return null; + } + else if (left == null) + { + return right.Trim('/'); + } + else if (right == null) + { + return left.Trim('/'); + } + + // Neither is null + var trimmedLeft = left.Trim('/'); + var trimmedRight = right.Trim('/'); + + if (trimmedLeft == string.Empty) + { + return trimmedRight; + } + else if (trimmedRight == string.Empty) + { + return trimmedLeft; + } + + // Both templates contain some text. + return trimmedLeft + '/' + trimmedRight; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouting.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouting.cs new file mode 100644 index 0000000000..4e7cc43c16 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouting.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNet.Routing; +using Microsoft.AspNet.Routing.Template; +using Microsoft.Framework.DependencyInjection; + +namespace Microsoft.AspNet.Mvc.Routing +{ + public static class AttributeRouting + { + // Key used by routing and action selection to match an attribute route entry to a + // group of action descriptors. + public static readonly string RouteGroupKey = "!__route_group"; + + /// + /// Creates an attribute route using the provided services and provided target router. + /// + /// The router to invoke when a route entry matches. + /// The application services. + /// An attribute route. + public static IRouter CreateAttributeMegaRoute([NotNull] IRouter target, [NotNull] IServiceProvider services) + { + var actions = GetActionDescriptors(services); + + // We're creating one AttributeRouteEntry per group, so we need to identify the distinct set of + // groups. It's guaranteed that all members of the group have the same template and precedence, + // so we only need to hang on to a single instance of the template. + var routeTemplatesByGroup = GroupTemplatesByGroupId(actions); + + var inlineConstraintResolver = services.GetService(); + + var entries = new List(); + foreach (var routeGroup in routeTemplatesByGroup) + { + var routeGroupId = routeGroup.Key; + var template = routeGroup.Value; + + var parsedTemplate = TemplateParser.Parse(template, inlineConstraintResolver); + var precedence = AttributeRoutePrecedence.Compute(parsedTemplate); + + entries.Add(new AttributeRouteEntry() + { + Precedence = precedence, + Route = new TemplateRoute( + target, + template, + defaults: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { RouteGroupKey, routeGroupId }, + }, + constraints: null, + inlineConstraintResolver: inlineConstraintResolver), + }); + } + + return new AttributeRoute(target, entries); + } + + private static IReadOnlyList GetActionDescriptors(IServiceProvider services) + { + var actionDescriptorProvider = services.GetService(); + + var actionDescriptorsCollection = actionDescriptorProvider.ActionDescriptors; + return actionDescriptorsCollection.Items; + } + + private static Dictionary GroupTemplatesByGroupId(IReadOnlyList actions) + { + var routeTemplatesByGroup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var action in actions.Where(a => a.RouteTemplate != null)) + { + var constraint = action.RouteConstraints + .Where(c => c.RouteKey == AttributeRouting.RouteGroupKey) + .FirstOrDefault(); + if (constraint == null || + constraint.KeyHandling != RouteKeyHandling.RequireKey || + constraint.RouteValue == null) + { + // This is unlikely to happen by default, but could happen through extensibility. Just ignore it. + continue; + } + + var routeGroup = constraint.RouteValue; + if (!routeTemplatesByGroup.ContainsKey(routeGroup)) + { + routeTemplatesByGroup.Add(routeGroup, action.RouteTemplate); + } + } + + return routeTemplatesByGroup; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/IRouteTemplateProvider.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/IRouteTemplateProvider.cs new file mode 100644 index 0000000000..2dc7969d2d --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/IRouteTemplateProvider.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Mvc.Routing +{ + /// + /// Interface for attributes which can supply a route template for attribute routing. + /// + public interface IRouteTemplateProvider + { + /// + /// The route template. May be null. + /// + string Template { get; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc/BuilderExtensions.cs b/src/Microsoft.AspNet.Mvc/BuilderExtensions.cs index 3cb938763a..d274b40e01 100644 --- a/src/Microsoft.AspNet.Mvc/BuilderExtensions.cs +++ b/src/Microsoft.AspNet.Mvc/BuilderExtensions.cs @@ -3,6 +3,7 @@ using System; using Microsoft.AspNet.Mvc; +using Microsoft.AspNet.Mvc.Routing; using Microsoft.AspNet.Routing; namespace Microsoft.AspNet.Builder @@ -29,6 +30,10 @@ namespace Microsoft.AspNet.Builder ServiceProvider = app.ApplicationServices }; + routes.Routes.Add(AttributeRouting.CreateAttributeMegaRoute( + routes.DefaultHandler, + app.ApplicationServices)); + configureRoutes(routes); return app.UseRouter(routes.Build()); diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ActionAttributeTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ActionAttributeTests.cs index c68d7a034b..da35e2e606 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ActionAttributeTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ActionAttributeTests.cs @@ -220,7 +220,8 @@ namespace Microsoft.AspNet.Mvc.Test controllerAssemblyProvider.Object, actionDiscoveryConventions, null, - new MockMvcOptionsAccessor()); + new MockMvcOptionsAccessor(), + Mock.Of()); } private static HttpContext GetHttpContext(string httpMethod) @@ -327,4 +328,4 @@ namespace Microsoft.AspNet.Mvc.Test } } -#endif \ No newline at end of file +#endif diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ActionSelectionConventionTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ActionSelectionConventionTests.cs index 649b082cb5..d66139f5a3 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ActionSelectionConventionTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ActionSelectionConventionTests.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.Design; +using System.Linq; using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNet.Routing; @@ -187,7 +188,8 @@ namespace Microsoft.AspNet.Mvc.Test controllerAssemblyProvider.Object, actionDiscoveryConventions, null, - new MockMvcOptionsAccessor()); + new MockMvcOptionsAccessor(), + Mock.Of()); } private static HttpContext GetHttpContext(string httpMethod) @@ -307,4 +309,4 @@ namespace Microsoft.AspNet.Mvc.Test } } -#endif \ No newline at end of file +#endif diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj b/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj index ace2c4d398..1c9c7e260a 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj @@ -67,6 +67,8 @@ + + diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedActionDescriptorProviderTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedActionDescriptorProviderTests.cs index 930466d47c..633e36c195 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedActionDescriptorProviderTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ReflectedActionDescriptorProviderTests.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using Microsoft.AspNet.Routing; using Moq; using Xunit; @@ -297,36 +298,38 @@ namespace Microsoft.AspNet.Mvc.Test TypeInfo controllerTypeInfo, IEnumerable filters = null) { + var conventions = new StaticActionDiscoveryConventions(controllerTypeInfo); + var assemblyProvider = new Mock(); assemblyProvider .SetupGet(ap => ap.CandidateAssemblies) .Returns(new Assembly[] { controllerTypeInfo.Assembly }); - var conventions = new StaticActionDiscoveryConventions(controllerTypeInfo); - var provider = new ReflectedActionDescriptorProvider( assemblyProvider.Object, conventions, filters, - new MockMvcOptionsAccessor()); + new MockMvcOptionsAccessor(), + Mock.Of()); return provider; } private IEnumerable GetDescriptors(params TypeInfo[] controllerTypeInfos) { + var conventions = new StaticActionDiscoveryConventions(controllerTypeInfos); + var assemblyProvider = new Mock(); assemblyProvider .SetupGet(ap => ap.CandidateAssemblies) .Returns(controllerTypeInfos.Select(cti => cti.Assembly).Distinct()); - var conventions = new StaticActionDiscoveryConventions(controllerTypeInfos); - var provider = new ReflectedActionDescriptorProvider( assemblyProvider.Object, conventions, null, - new MockMvcOptionsAccessor()); + new MockMvcOptionsAccessor(), + null); return provider.GetDescriptors(); } diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRoutePrecedenceTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRoutePrecedenceTests.cs new file mode 100644 index 0000000000..ba97ea4d4f --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRoutePrecedenceTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#if NET45 +using Microsoft.AspNet.Routing; +using Microsoft.AspNet.Routing.Template; +using Microsoft.Framework.OptionsModel; +using Moq; +using System; +using Xunit; + +namespace Microsoft.AspNet.Mvc.Routing +{ + public class AttributeRoutePrecedenceTests + { + [Theory] + [InlineData("Employees/{id}", "Employees/{employeeId}")] + [InlineData("abc", "def")] + [InlineData("{x:alpha}", "{x:int}")] + public void Compute_IsEqual(string xTemplate, string yTemplate) + { + // Arrange & Act + var xPrededence = Compute(xTemplate); + var yPrededence = Compute(yTemplate); + + // Assert + Assert.Equal(xPrededence, yPrededence); + } + + [Theory] + [InlineData("abc", "a{x}")] + [InlineData("abc", "{x}c")] + [InlineData("abc", "{x:int}")] + [InlineData("abc", "{x}")] + [InlineData("abc", "{*x}")] + [InlineData("{x:int}", "{x}")] + [InlineData("{x:int}", "{*x}")] + [InlineData("a{x}", "{x}")] + [InlineData("{x}c", "{x}")] + [InlineData("a{x}", "{*x}")] + [InlineData("{x}c", "{*x}")] + [InlineData("{x}", "{*x}")] + [InlineData("{*x:maxlength(10)}", "{*x}")] + [InlineData("abc/def", "abc/{x:int}")] + [InlineData("abc/def", "abc/{x}")] + [InlineData("abc/def", "abc/{*x}")] + [InlineData("abc/{x:int}", "abc/{x}")] + [InlineData("abc/{x:int}", "abc/{*x}")] + [InlineData("abc/{x}", "abc/{*x}")] + [InlineData("{x}/{y:int}", "{x}/{y}")] + public void Compute_IsLessThan(string xTemplate, string yTemplate) + { + // Arrange & Act + var xPrededence = Compute(xTemplate); + var yPrededence = Compute(yTemplate); + + // Assert + Assert.True(xPrededence < yPrededence); + } + + private static decimal Compute(string template) + { + var options = new Mock>(); + options.SetupGet(o => o.Options).Returns(new RouteOptions()); + + var constraintResolver = new DefaultInlineConstraintResolver( + Mock.Of(), + options.Object); + + var parsed = TemplateParser.Parse(template, constraintResolver); + return AttributeRoutePrecedence.Compute(parsed); + } + } +} +#endif \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRouteTemplateTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRouteTemplateTests.cs new file mode 100644 index 0000000000..0b7dfc0661 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRouteTemplateTests.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; + +namespace Microsoft.AspNet.Mvc.Routing +{ + public class AttributeRouteTemplateTests + { + [Theory] + [InlineData(null, null, null)] + [InlineData("", null, "")] + [InlineData(null, "", "")] + [InlineData("/", null, "")] + [InlineData(null, "/", "")] + [InlineData("/", "", "")] + [InlineData("", "/", "")] + [InlineData("/", "/", "")] + [InlineData("/", "/", "")] + public void Combine_EmptyTemplates(string left, string right, string expected) + { + // Arrange & Act + var combined = AttributeRouteTemplate.Combine(left, right); + + // Assert + Assert.Equal(expected, combined); + } + + [Theory] + [InlineData("home", null, "home")] + [InlineData("home", "", "home")] + [InlineData("/home/", "/", "home")] + [InlineData(null, "GetEmployees", "GetEmployees")] + [InlineData("/", "GetEmployees", "GetEmployees")] + [InlineData("", "/GetEmployees/{id}/", "GetEmployees/{id}")] + public void Combine_OneTemplateHasValue(string left, string right, string expected) + { + // Arrange & Act + var combined = AttributeRouteTemplate.Combine(left, right); + + // Assert + Assert.Equal(expected, combined); + } + + [Theory] + [InlineData("home", "About", "home/About")] + [InlineData("home/", "/About", "home/About")] + [InlineData("/home/{action}", "{id}", "home/{action}/{id}")] + public void Combine_BothTemplatesHasValue(string left, string right, string expected) + { + // Arrange & Act + var combined = AttributeRouteTemplate.Combine(left, right); + + // Assert + Assert.Equal(expected, combined); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/Microsoft.AspNet.Mvc.FunctionalTests.kproj b/test/Microsoft.AspNet.Mvc.FunctionalTests/Microsoft.AspNet.Mvc.FunctionalTests.kproj index c2b262acd3..7479f566f7 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/Microsoft.AspNet.Mvc.FunctionalTests.kproj +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/Microsoft.AspNet.Mvc.FunctionalTests.kproj @@ -43,4 +43,4 @@ - + \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/RoutingTests.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/RoutingTests.cs index 0281dbc520..bdc3de8d1d 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/RoutingTests.cs +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/RoutingTests.cs @@ -134,6 +134,155 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests Assert.Equal(404, response.StatusCode); } + [Fact] + public async Task AttributeRoutedAction_IsReachable() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var response = await client.GetAsync("http://localhost/Store/Shop/Products"); + Assert.Equal(200, response.StatusCode); + + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Contains("/Store/Shop/Products", result.ExpectedUrls); + Assert.Equal("Store", result.Controller); + Assert.Equal("ListProducts", result.Action); + } + + // The url would be /Store/ListProducts with conventional routes + [Fact] + public async Task AttributeRoutedAction_IsNotReachableWithTraditionalRoute() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var response = await client.GetAsync("http://localhost/Store/ListProducts"); + + // Assert + Assert.Equal(404, response.StatusCode); + } + + // There's two actions at this URL - but attribute routes go in the route table + // first. + [Fact] + public async Task AttributeRoutedAction_TriedBeforeConventionRouting() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var response = await client.GetAsync("http://localhost/Home/About"); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Contains("/Home/About", result.ExpectedUrls); + Assert.Equal("Store", result.Controller); + Assert.Equal("About", result.Action); + + // A convention-routed action would have values for action and controller. + Assert.None( + result.RouteValues, + (kvp) => string.Equals(kvp.Key, "action", StringComparison.OrdinalIgnoreCase)); + + Assert.None( + result.RouteValues, + (kvp) => string.Equals(kvp.Key, "controller", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task AttributeRoutedAction_ControllerLevelRoute_WithActionParameter_IsReachable() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var response = await client.GetAsync("http://localhost/Blog/Edit/5"); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Contains("/Blog/Edit/5", result.ExpectedUrls); + Assert.Equal("Blog", result.Controller); + Assert.Equal("Edit", result.Action); + + // This route is parameterized on {action}, but not controller. + Assert.Contains( + new KeyValuePair("action", "Edit"), + result.RouteValues); + + Assert.Contains( + new KeyValuePair("postId", "5"), + result.RouteValues); + + Assert.None( + result.RouteValues, + (kvp) => string.Equals(kvp.Key, "controller", StringComparison.OrdinalIgnoreCase)); + } + + // There's no [HttpGet] on the action here. + [Fact] + public async Task AttributeRoutedAction_ControllerLevelRoute_IsReachable() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var response = await client.GetAsync("http://localhost/api/Employee"); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Contains("/api/Employee", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + } + + // There's an [HttpGet] with its own template on the action here. + [Fact] + public async Task AttributeRoutedAction_ControllerLevelRoute_CombinedWithActionRoute_IsReachable() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var response = await client.GetAsync("http://localhost/api/Employee/5/Boss"); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Contains("/api/Employee/5/Boss", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("GetBoss", result.Action); + + Assert.Contains( + new KeyValuePair("id", "5"), + result.RouteValues); + } + // See TestResponseGenerator in RoutingWebSite for the code that generates this data. private class RoutingResult { diff --git a/test/WebSites/RoutingWebSite/Controllers/BlogController.cs b/test/WebSites/RoutingWebSite/Controllers/BlogController.cs new file mode 100644 index 0000000000..b702aab21e --- /dev/null +++ b/test/WebSites/RoutingWebSite/Controllers/BlogController.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace RoutingWebSite +{ + // This controller contains actions mapped with a single controller-level route. + [Route("Blog/{action=ShowPosts}/{postId?}")] + public class BlogController + { + private readonly TestResponseGenerator _generator; + + public BlogController(TestResponseGenerator generator) + { + _generator = generator; + } + + public IActionResult ShowPosts() + { + return _generator.Generate("/Blog", "/Blog/ShowPosts"); + } + + public IActionResult Edit(int postId) + { + return _generator.Generate("/Blog/Edit/" + postId); + } + } +} \ No newline at end of file diff --git a/test/WebSites/RoutingWebSite/Controllers/EmployeeController.cs b/test/WebSites/RoutingWebSite/Controllers/EmployeeController.cs new file mode 100644 index 0000000000..dc21fa9df9 --- /dev/null +++ b/test/WebSites/RoutingWebSite/Controllers/EmployeeController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNet.Mvc; +using System; + +namespace RoutingWebSite +{ + // This controller combines routes on the controller with routes on actions in a REST + navigation property + // style. + [Route("api/Employee")] + public class EmployeeController : Controller + { + private readonly TestResponseGenerator _generator; + + public EmployeeController(TestResponseGenerator generator) + { + _generator = generator; + } + + public IActionResult List() + { + return _generator.Generate("/api/Employee"); + } + + [HttpGet("{id}")] + public IActionResult Get(int id) + { + return _generator.Generate("/api/Employee/" + id); + } + + [HttpGet("{id}/Boss")] + public IActionResult GetBoss(int id) + { + return _generator.Generate("/api/Employee/" + id + "/Boss"); + } + } +} \ No newline at end of file diff --git a/test/WebSites/RoutingWebSite/Controllers/HomeController.cs b/test/WebSites/RoutingWebSite/Controllers/HomeController.cs index ba4a876f7c..b6957f3294 100644 --- a/test/WebSites/RoutingWebSite/Controllers/HomeController.cs +++ b/test/WebSites/RoutingWebSite/Controllers/HomeController.cs @@ -22,6 +22,7 @@ namespace RoutingWebSite public IActionResult About() { + // There are no urls that reach this action - it's hidden by an attribute route. return _generator.Generate(); } } diff --git a/test/WebSites/RoutingWebSite/Controllers/StoreController.cs b/test/WebSites/RoutingWebSite/Controllers/StoreController.cs new file mode 100644 index 0000000000..14150b65fa --- /dev/null +++ b/test/WebSites/RoutingWebSite/Controllers/StoreController.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace RoutingWebSite +{ + // This controller contains only actions with individual attribute routes. + public class StoreController : Controller + { + private readonly TestResponseGenerator _generator; + + public StoreController(TestResponseGenerator generator) + { + _generator = generator; + } + + [HttpGet("Store/Shop/Products")] + public IActionResult ListProducts() + { + return _generator.Generate("/Store/Shop/Products"); + } + + // Intentionally designed to conflict with HomeController#About. + [HttpGet("Home/About")] + public IActionResult About() + { + return _generator.Generate("/Home/About"); + } + } +} \ No newline at end of file diff --git a/test/WebSites/RoutingWebSite/RoutingWebSite.kproj b/test/WebSites/RoutingWebSite/RoutingWebSite.kproj index 6aceb03b05..79c4fb17ed 100644 --- a/test/WebSites/RoutingWebSite/RoutingWebSite.kproj +++ b/test/WebSites/RoutingWebSite/RoutingWebSite.kproj @@ -29,7 +29,10 @@ + + +