diff --git a/build/dependencies.props b/build/dependencies.props index a2f537f41d..7c65067dfa 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -48,8 +48,8 @@ 3.0.0-alpha1-10617 3.0.0-alpha1-10617 3.0.0-alpha1-10617 - 3.0.0-alpha1-10617 - 3.0.0-alpha1-10617 + 3.0.0-a-alpha1-master-builder-17073 + 3.0.0-a-alpha1-master-builder-17073 3.0.0-alpha1-10617 3.0.0-alpha1-10617 3.0.0-alpha1-10617 diff --git a/samples/MvcSandbox/AuthorizationMiddleware/AuthorizationAppBuilderExtensions.cs b/samples/MvcSandbox/AuthorizationMiddleware/AuthorizationAppBuilderExtensions.cs new file mode 100644 index 0000000000..6a2fa48911 --- /dev/null +++ b/samples/MvcSandbox/AuthorizationMiddleware/AuthorizationAppBuilderExtensions.cs @@ -0,0 +1,21 @@ +// 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 MvcSandbox.AuthorizationMiddleware; + +namespace Microsoft.AspNetCore.Builder +{ + public static class AuthorizationAppBuilderExtensions + { + public static IApplicationBuilder UseAuthorization(this IApplicationBuilder app) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + return app.UseMiddleware(); + } + } +} \ No newline at end of file diff --git a/samples/MvcSandbox/AuthorizationMiddleware/AuthorizationEndpointConventionBuilder.cs b/samples/MvcSandbox/AuthorizationMiddleware/AuthorizationEndpointConventionBuilder.cs new file mode 100644 index 0000000000..5fb990e1f3 --- /dev/null +++ b/samples/MvcSandbox/AuthorizationMiddleware/AuthorizationEndpointConventionBuilder.cs @@ -0,0 +1,16 @@ +// 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.Routing; + +namespace MvcSandbox.AuthorizationMiddleware +{ + public static class AuthorizationEndpointConventionBuilder + { + public static T RequireAuthorization(this T builder, params string[] roles) where T : IEndpointConventionBuilder + { + builder.Apply(model => model.Metadata.Add(new AuthorizeMetadataAttribute(roles))); + return builder; + } + } +} diff --git a/samples/MvcSandbox/AuthorizationMiddleware/AuthorizationMiddleware.cs b/samples/MvcSandbox/AuthorizationMiddleware/AuthorizationMiddleware.cs new file mode 100644 index 0000000000..23a5e45f3f --- /dev/null +++ b/samples/MvcSandbox/AuthorizationMiddleware/AuthorizationMiddleware.cs @@ -0,0 +1,52 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; + +namespace MvcSandbox.AuthorizationMiddleware +{ + public class AuthorizationMiddleware + { + private readonly RequestDelegate _next; + + public AuthorizationMiddleware(RequestDelegate next) + { + if (next == null) + { + throw new ArgumentNullException(nameof(next)); + } + + _next = next; + } + + public async Task Invoke(HttpContext httpContext) + { + var endpoint = httpContext.Features.Get()?.Endpoint; + var metadata = endpoint?.Metadata?.GetMetadata(); + + // Only run authorization if endpoint has metadata + if (metadata != null) + { + // Check if role querystring value is a valid role + if (!httpContext.Request.Query.TryGetValue("role", out var role) || + !metadata.Roles.Contains(role.ToString(), StringComparer.OrdinalIgnoreCase)) + { + httpContext.Response.StatusCode = 401; + httpContext.Response.ContentType = "text/plain"; + await httpContext.Response.WriteAsync($"Unauthorized access to '{endpoint.DisplayName}'."); + return; + } + } + + await _next(httpContext); + } + } +} \ No newline at end of file diff --git a/samples/MvcSandbox/AuthorizationMiddleware/AuthorizeMetadataAttribute.cs b/samples/MvcSandbox/AuthorizationMiddleware/AuthorizeMetadataAttribute.cs new file mode 100644 index 0000000000..7c95327d0d --- /dev/null +++ b/samples/MvcSandbox/AuthorizationMiddleware/AuthorizeMetadataAttribute.cs @@ -0,0 +1,20 @@ +// 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.Threading.Tasks; + +namespace MvcSandbox.AuthorizationMiddleware +{ + public class AuthorizeMetadataAttribute : Attribute + { + public AuthorizeMetadataAttribute(string[] roles) + { + Roles = roles; + } + + public string[] Roles { get; } + } +} diff --git a/samples/MvcSandbox/Controllers/HomeController.cs b/samples/MvcSandbox/Controllers/HomeController.cs index 2aa4ff6829..87406bce53 100644 --- a/samples/MvcSandbox/Controllers/HomeController.cs +++ b/samples/MvcSandbox/Controllers/HomeController.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Mvc; +using MvcSandbox.AuthorizationMiddleware; namespace MvcSandbox.Controllers { diff --git a/samples/MvcSandbox/Controllers/LoginController.cs b/samples/MvcSandbox/Controllers/LoginController.cs new file mode 100644 index 0000000000..5110850fbf --- /dev/null +++ b/samples/MvcSandbox/Controllers/LoginController.cs @@ -0,0 +1,16 @@ +// 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; + +namespace MvcSandbox.Controllers +{ + [Route("[controller]/[action]")] + public class LoginController : Controller + { + public IActionResult Index() + { + return View(); + } + } +} diff --git a/samples/MvcSandbox/HealthChecks/HealthChecksEndpointRouteBuilderExtensions.cs b/samples/MvcSandbox/HealthChecks/HealthChecksEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000000..b89acdd2b4 --- /dev/null +++ b/samples/MvcSandbox/HealthChecks/HealthChecksEndpointRouteBuilderExtensions.cs @@ -0,0 +1,28 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Builder +{ + public static class HealthChecksEndpointRouteBuilderExtensions + { + private static readonly Random _random = new Random(); + + public static IEndpointConventionBuilder MapHealthChecks(this IEndpointRouteBuilder builder, string pattern) + { + return builder.MapGet( + pattern, + async httpContext => + { + await httpContext.Response.WriteAsync(_random.Next() % 2 == 0 ? "Up!" : "Down!"); + }); + } + } +} diff --git a/samples/MvcSandbox/Startup.cs b/samples/MvcSandbox/Startup.cs index d9f96bf08b..12c1291669 100644 --- a/samples/MvcSandbox/Startup.cs +++ b/samples/MvcSandbox/Startup.cs @@ -1,11 +1,18 @@ // 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.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using MvcSandbox.AuthorizationMiddleware; namespace MvcSandbox { @@ -20,14 +27,46 @@ namespace MvcSandbox // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app) { - app.UseDeveloperExceptionPage(); - app.UseStaticFiles(); - app.UseMvc(routes => + app.UseEndpointRouting(builder => { - routes.MapRoute( + builder.MapGet( + requestDelegate: WriteEndpoints, + pattern: "/endpoints", + displayName: "Home"); + + builder.MapMvcRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); + + builder.MapMvcControllers(); + builder.MapRazorPages(); + + builder.MapHealthChecks("/healthz"); }); + + app.UseDeveloperExceptionPage(); + app.UseStaticFiles(); + + app.UseAuthorization(); + + app.UseEndpoint(); + } + + private static Task WriteEndpoints(HttpContext httpContext) + { + var dataSource = httpContext.RequestServices.GetRequiredService(); + + var sb = new StringBuilder(); + sb.AppendLine("Endpoints:"); + foreach (var endpoint in dataSource.Endpoints.OfType().OrderBy(e => e.RoutePattern.RawText, StringComparer.OrdinalIgnoreCase)) + { + sb.AppendLine($"- {endpoint.RoutePattern.RawText} '{endpoint.DisplayName}'"); + } + + var response = httpContext.Response; + response.StatusCode = 200; + response.ContentType = "text/plain"; + return response.WriteAsync(sb.ToString()); } public static void Main(string[] args) diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Builder/DefaultEndpointConventionBuilder.cs b/src/Microsoft.AspNetCore.Mvc.Core/Builder/DefaultEndpointConventionBuilder.cs new file mode 100644 index 0000000000..8427ba5c3b --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/Builder/DefaultEndpointConventionBuilder.cs @@ -0,0 +1,24 @@ +// 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.Routing; + +namespace Microsoft.AspNetCore.Builder +{ + internal class DefaultEndpointConventionBuilder : IEndpointConventionBuilder + { + public DefaultEndpointConventionBuilder() + { + Conventions = new List>(); + } + + public List> Conventions { get; } + + public void Apply(Action convention) + { + Conventions.Add(convention); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcApplicationBuilderExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcApplicationBuilderExtensions.cs index 06e26ed407..6cde8eccd5 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcApplicationBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcApplicationBuilderExtensions.cs @@ -90,9 +90,7 @@ namespace Microsoft.AspNetCore.Builder if (options.Value.EnableEndpointRouting) { var mvcEndpointDataSource = app.ApplicationServices - .GetRequiredService>() - .OfType() - .First(); + .GetRequiredService(); var parameterPolicyFactory = app.ApplicationServices .GetRequiredService(); @@ -122,11 +120,21 @@ namespace Microsoft.AspNetCore.Builder } } + // Include all controllers with attribute routing and Razor pages + var defaultEndpointConventionBuilder = new DefaultEndpointConventionBuilder(); + mvcEndpointDataSource.AttributeRoutingConventionResolvers.Add((actionDescriptor) => + { + return defaultEndpointConventionBuilder; + }); + if (!app.Properties.TryGetValue(EndpointRoutingRegisteredKey, out _)) { // Matching middleware has not been registered yet // For back-compat register middleware so an endpoint is matched and then immediately used - app.UseEndpointRouting(); + app.UseEndpointRouting(routerBuilder => + { + routerBuilder.DataSources.Add(mvcEndpointDataSource); + }); } return app.UseEndpoint(); diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfo.cs b/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfo.cs index 161189b871..289a1a2f34 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfo.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointInfo.cs @@ -9,7 +9,7 @@ using Microsoft.AspNetCore.Routing.Patterns; namespace Microsoft.AspNetCore.Builder { - internal class MvcEndpointInfo + internal class MvcEndpointInfo : DefaultEndpointConventionBuilder { public MvcEndpointInfo( string name, @@ -43,6 +43,7 @@ namespace Microsoft.AspNetCore.Builder public string Name { get; } public string Pattern { get; } + public Type ControllerType { get; set; } // Non-inline defaults public RouteValueDictionary Defaults { get; } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointRouteBuilderExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000000..a3bd2d9951 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/Builder/MvcEndpointRouteBuilderExtensions.cs @@ -0,0 +1,148 @@ +// 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.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Builder +{ + public static class MvcEndpointRouteBuilderExtensions + { + public static IEndpointConventionBuilder MapMvcControllers( + this IEndpointRouteBuilder routeBuilder) + { + return MapMvcControllers(routeBuilder); + } + + public static IEndpointConventionBuilder MapMvcControllers( + this IEndpointRouteBuilder routeBuilder) where TController : ControllerBase + { + var mvcEndpointDataSource = routeBuilder.DataSources.OfType().FirstOrDefault(); + + if (mvcEndpointDataSource == null) + { + mvcEndpointDataSource = routeBuilder.ServiceProvider.GetRequiredService(); + routeBuilder.DataSources.Add(mvcEndpointDataSource); + } + + var conventionBuilder = new DefaultEndpointConventionBuilder(); + + mvcEndpointDataSource.AttributeRoutingConventionResolvers.Add(actionDescriptor => + { + if (actionDescriptor is ControllerActionDescriptor controllerActionDescriptor && + typeof(TController).IsAssignableFrom(controllerActionDescriptor.ControllerTypeInfo)) + { + return conventionBuilder; + } + + return null; + }); + + return conventionBuilder; + } + + public static IEndpointConventionBuilder MapMvcRoute( + this IEndpointRouteBuilder routeBuilder, + string name, + string template) + { + return MapMvcRoute(routeBuilder, name, template, defaults: null); + } + + public static IEndpointConventionBuilder MapMvcRoute( + this IEndpointRouteBuilder routeBuilder, + string name, + string template, + object defaults) + { + return MapMvcRoute(routeBuilder, name, template, defaults, constraints: null); + } + + public static IEndpointConventionBuilder MapMvcRoute( + this IEndpointRouteBuilder routeBuilder, + string name, + string template, + object defaults, + object constraints) + { + return MapMvcRoute(routeBuilder, name, template, defaults, constraints, dataTokens: null); + } + + public static IEndpointConventionBuilder MapMvcRoute( + this IEndpointRouteBuilder routeBuilder, + string name, + string template, + object defaults, + object constraints, + object dataTokens) + { + return MapMvcRoute(routeBuilder, name, template, defaults, constraints, dataTokens); + } + + public static IEndpointConventionBuilder MapMvcRoute( + this IEndpointRouteBuilder routeBuilder, + string name, + string template) where TController : ControllerBase + { + return MapMvcRoute(routeBuilder, name, template, defaults: null); + } + + public static IEndpointConventionBuilder MapMvcRoute( + this IEndpointRouteBuilder routeBuilder, + string name, + string template, + object defaults) where TController : ControllerBase + { + return MapMvcRoute(routeBuilder, name, template, defaults, constraints: null); + } + + public static IEndpointConventionBuilder MapMvcRoute( + this IEndpointRouteBuilder routeBuilder, + string name, + string template, + object defaults, + object constraints) where TController : ControllerBase + { + return MapMvcRoute(routeBuilder, name, template, defaults, constraints, dataTokens: null); + } + + public static IEndpointConventionBuilder MapMvcRoute( + this IEndpointRouteBuilder routeBuilder, + string name, + string template, + object defaults, + object constraints, + object dataTokens) where TController : ControllerBase + { + var mvcEndpointDataSource = routeBuilder.DataSources.OfType().FirstOrDefault(); + + if (mvcEndpointDataSource == null) + { + mvcEndpointDataSource = routeBuilder.ServiceProvider.GetRequiredService(); + routeBuilder.DataSources.Add(mvcEndpointDataSource); + } + + var endpointInfo = new MvcEndpointInfo( + name, + template, + new RouteValueDictionary(defaults), + new RouteValueDictionary(constraints), + new RouteValueDictionary(dataTokens), + routeBuilder.ServiceProvider.GetRequiredService()); + + endpointInfo.ControllerType = typeof(TController); + + mvcEndpointDataSource.ConventionalEndpointInfos.Add(endpointInfo); + + return endpointInfo; + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs index 95a4afb21b..d645322384 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs @@ -270,8 +270,7 @@ namespace Microsoft.Extensions.DependencyInjection // // Endpoint Routing / Endpoints // - services.TryAddEnumerable( - ServiceDescriptor.Singleton()); + services.TryAddSingleton(); services.TryAddSingleton(); // diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs index d371d02f79..c0e5215259 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs @@ -10,6 +10,7 @@ using System.Threading; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; @@ -57,6 +58,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal _parameterPolicyFactory = parameterPolicyFactory; ConventionalEndpointInfos = new List(); + AttributeRoutingConventionResolvers = new List>(); // IMPORTANT: this needs to be the last thing we do in the constructor. Change notifications can happen immediately! // @@ -72,6 +74,8 @@ namespace Microsoft.AspNetCore.Mvc.Internal public List ConventionalEndpointInfos { get; } + public List> AttributeRoutingConventionResolvers { get; } + public override IReadOnlyList Endpoints { get @@ -134,6 +138,16 @@ namespace Microsoft.AspNetCore.Mvc.Internal // - Home/Login foreach (var endpointInfo in ConventionalEndpointInfos) { + if (endpointInfo.ControllerType != null && + endpointInfo.ControllerType != typeof(ControllerBase)) + { + if (!ValidateControllerConstraint(action, endpointInfo)) + { + // Action descriptor does not belong to a controller of the specified type + continue; + } + } + // An 'endpointInfo' is applicable if: // 1. it has a parameter (or default value) for 'required' non-null route value // 2. it does not have a parameter (or default value) for 'required' null route value @@ -164,11 +178,20 @@ namespace Microsoft.AspNetCore.Mvc.Internal endpointInfo.DataTokens, endpointInfo.ParameterPolicies, suppressLinkGeneration: false, - suppressPathMatching: false); + suppressPathMatching: false, + endpointInfo.Conventions); } } else { + var conventionBuilder = ResolveActionConventionBuilder(action); + if (conventionBuilder == null) + { + // No convention builder for this action + // Do not create an endpoint for it + continue; + } + var attributeRoutePattern = RoutePatternFactory.Parse(action.AttributeRouteInfo.Template); CreateEndpoints( @@ -183,7 +206,8 @@ namespace Microsoft.AspNetCore.Mvc.Internal dataTokens: null, allParameterPolicies: null, action.AttributeRouteInfo.SuppressLinkGeneration, - action.AttributeRouteInfo.SuppressPathMatching); + action.AttributeRouteInfo.SuppressPathMatching, + conventionBuilder.Conventions); } } @@ -205,6 +229,30 @@ namespace Microsoft.AspNetCore.Mvc.Internal } } + private DefaultEndpointConventionBuilder ResolveActionConventionBuilder(ActionDescriptor action) + { + foreach (var filter in AttributeRoutingConventionResolvers) + { + var conventionBuilder = filter(action); + if (conventionBuilder != null) + { + return conventionBuilder; + } + } + + return null; + } + + private static bool ValidateControllerConstraint(ActionDescriptor action, MvcEndpointInfo endpointInfo) + { + if (action is ControllerActionDescriptor controllerActionDescriptor) + { + return endpointInfo.ControllerType.IsAssignableFrom(controllerActionDescriptor.ControllerTypeInfo); + } + + return false; + } + // CreateEndpoints processes the route pattern, replacing area/controller/action parameters with endpoint values // Because of default values it is possible for a route pattern to resolve to multiple endpoints private int CreateEndpoints( @@ -219,7 +267,8 @@ namespace Microsoft.AspNetCore.Mvc.Internal RouteValueDictionary dataTokens, IDictionary> allParameterPolicies, bool suppressLinkGeneration, - bool suppressPathMatching) + bool suppressPathMatching, + List> conventions) { var newPathSegments = routePattern.PathSegments.ToList(); @@ -245,7 +294,8 @@ namespace Microsoft.AspNetCore.Mvc.Internal routeOrder++, dataTokens, suppressLinkGeneration, - suppressPathMatching); + suppressPathMatching, + conventions); endpoints.Add(subEndpoint); } @@ -314,7 +364,8 @@ namespace Microsoft.AspNetCore.Mvc.Internal routeOrder++, dataTokens, suppressLinkGeneration, - suppressPathMatching); + suppressPathMatching, + conventions); endpoints.Add(endpoint); return routeOrder; @@ -448,7 +499,8 @@ namespace Microsoft.AspNetCore.Mvc.Internal int order, RouteValueDictionary dataTokens, bool suppressLinkGeneration, - bool suppressPathMatching) + bool suppressPathMatching, + List> conventions) { RequestDelegate requestDelegate = (context) => { @@ -463,7 +515,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal var defaults = new RouteValueDictionary(nonInlineDefaults); EnsureRequiredValuesInDefaults(action.RouteValues, defaults); - var metadataCollection = BuildEndpointMetadata( + var model = new RouteEndpointModel(requestDelegate, RoutePatternFactory.Pattern(patternRawText, defaults, parameterPolicies: null, segments), order); + + AddEndpointMetadata( + model.Metadata, action, routeName, new RouteValueDictionary(action.RouteValues), @@ -471,17 +526,21 @@ namespace Microsoft.AspNetCore.Mvc.Internal suppressLinkGeneration, suppressPathMatching); - var endpoint = new RouteEndpoint( - requestDelegate, - RoutePatternFactory.Pattern(patternRawText, defaults, parameterPolicies: null, segments), - order, - metadataCollection, - action.DisplayName); + model.DisplayName = action.DisplayName; - return endpoint; + if (conventions != null) + { + foreach (var convention in conventions) + { + convention(model); + } + } + + return (RouteEndpoint)model.Build(); } - private static EndpointMetadataCollection BuildEndpointMetadata( + private static void AddEndpointMetadata( + IList metadata, ActionDescriptor action, string routeName, RouteValueDictionary requiredValues, @@ -489,14 +548,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal bool suppressLinkGeneration, bool suppressPathMatching) { - var metadata = new List - { - action - }; + metadata.Add(action); if (action.EndpointMetadata != null) { - metadata.AddRange(action.EndpointMetadata); + foreach (var d in action.EndpointMetadata) + { + metadata.Add(d); + } } if (dataTokens != null) @@ -509,8 +568,10 @@ namespace Microsoft.AspNetCore.Mvc.Internal // 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)); + foreach (var filter in action.FilterDescriptors.OrderBy(f => f, FilterDescriptorOrderComparer.Comparer).Select(f => f.Filter)) + { + metadata.Add(filter); + } } if (action.ActionConstraints != null && action.ActionConstraints.Count > 0) @@ -549,9 +610,6 @@ namespace Microsoft.AspNetCore.Mvc.Internal { metadata.Add(new SuppressMatchingMetadata()); } - - var metadataCollection = new EndpointMetadataCollection(metadata); - return metadataCollection; } // Ensure required values are a subset of defaults diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Builder/RazorPagesEndpointRouteBuilderExtensions.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Builder/RazorPagesEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000000..bb69573b7e --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Builder/RazorPagesEndpointRouteBuilderExtensions.cs @@ -0,0 +1,47 @@ +// 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.Mvc; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Builder +{ + public static class RazorPagesEndpointRouteBuilderExtensions + { + public static IEndpointConventionBuilder MapRazorPages( + this IEndpointRouteBuilder routeBuilder, + string basePath = null) + { + var mvcEndpointDataSource = routeBuilder.DataSources.OfType().FirstOrDefault(); + + if (mvcEndpointDataSource == null) + { + mvcEndpointDataSource = routeBuilder.ServiceProvider.GetRequiredService(); + routeBuilder.DataSources.Add(mvcEndpointDataSource); + } + + var conventionBuilder = new DefaultEndpointConventionBuilder(); + + mvcEndpointDataSource.AttributeRoutingConventionResolvers.Add(actionDescriptor => + { + if (actionDescriptor is PageActionDescriptor pageActionDescriptor) + { + // TODO: Filter pages by path + return conventionBuilder; + } + + return null; + }); + + return conventionBuilder; + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcApplicationBuilderExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcApplicationBuilderExtensionsTest.cs index 3bd2cfa1f6..1d69fd9d5e 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcApplicationBuilderExtensionsTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Builder/MvcApplicationBuilderExtensionsTest.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Builder.Internal; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Moq; using Xunit; @@ -56,12 +57,10 @@ namespace Microsoft.AspNetCore.Mvc.Core.Builder template: "{controller=Home}/{action=Index}/{id?}"); }); - var mvcEndpointDataSource = appBuilder.ApplicationServices - .GetRequiredService>() - .OfType() - .First(); + var routeOptions = appBuilder.ApplicationServices + .GetRequiredService>(); - Assert.Empty(mvcEndpointDataSource.ConventionalEndpointInfos); + Assert.Empty(routeOptions.Value.EndpointDataSources); } [Fact] @@ -83,10 +82,10 @@ namespace Microsoft.AspNetCore.Mvc.Core.Builder template: "{controller=Home}/{action=Index}/{id?}"); }); - var mvcEndpointDataSource = appBuilder.ApplicationServices - .GetRequiredService>() - .OfType() - .First(); + var routeOptions = appBuilder.ApplicationServices + .GetRequiredService>(); + + var mvcEndpointDataSource = (MvcEndpointDataSource)Assert.Single(routeOptions.Value.EndpointDataSources, ds => ds is MvcEndpointDataSource); var endpointInfo = Assert.Single(mvcEndpointDataSource.ConventionalEndpointInfos); Assert.Equal("default", endpointInfo.Name); diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs index e4e56bebd2..ee3b28f076 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs @@ -315,13 +315,6 @@ namespace Microsoft.AspNetCore.Mvc typeof(ApiBehaviorApplicationModelProvider), } }, - { - typeof(EndpointDataSource), - new Type[] - { - typeof(MvcEndpointDataSource), - } - }, { typeof(IStartupFilter), new Type[] diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs index 24622f85cd..2b4eccf70f 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs @@ -804,6 +804,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal mvcEndpointInvokerFactory ?? new MvcEndpointInvokerFactory(new ActionInvokerFactory(Array.Empty())), serviceProvider.GetRequiredService()); + var defaultEndpointConventionBuilder = new DefaultEndpointConventionBuilder(); + dataSource.AttributeRoutingConventionResolvers.Add((actionDescriptor) => + { + return defaultEndpointConventionBuilder; + }); + return dataSource; } diff --git a/test/Microsoft.AspNetCore.Mvc.Test/MvcServiceCollectionExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.Test/MvcServiceCollectionExtensionsTest.cs index 70fea5804a..4c1bfc7e78 100644 --- a/test/Microsoft.AspNetCore.Mvc.Test/MvcServiceCollectionExtensionsTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Test/MvcServiceCollectionExtensionsTest.cs @@ -353,6 +353,7 @@ namespace Microsoft.AspNetCore.Mvc new Type[] { typeof(MvcCoreRouteOptionsSetup), + typeof(MvcCoreRouteOptionsSetup), } }, {