diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 4532909a61d7..20d456087930 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -1,7 +1,9 @@ #nullable enable *REMOVED*abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string! +*REMOVED*Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object? Microsoft.AspNetCore.Builder.EndpointBuilder.ServiceProvider.get -> System.IServiceProvider? Microsoft.AspNetCore.Builder.EndpointBuilder.ServiceProvider.set -> void +Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object! Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata() -> T! Microsoft.AspNetCore.Http.IRouteHandlerFilter.InvokeAsync(Microsoft.AspNetCore.Http.RouteHandlerInvocationContext! context, Microsoft.AspNetCore.Http.RouteHandlerFilterDelegate! next) -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata diff --git a/src/Http/Http.Abstractions/src/Routing/EndpointMetadataCollection.cs b/src/Http/Http.Abstractions/src/Routing/EndpointMetadataCollection.cs index f057c79b4f16..5e3dc1b77d1c 100644 --- a/src/Http/Http.Abstractions/src/Routing/EndpointMetadataCollection.cs +++ b/src/Http/Http.Abstractions/src/Routing/EndpointMetadataCollection.cs @@ -162,25 +162,26 @@ public T GetRequiredMetadata() where T : class /// /// Enumerates the elements of an . /// - public struct Enumerator : IEnumerator + public struct Enumerator : IEnumerator { #pragma warning disable IDE0044 // Intentionally not readonly to prevent defensive struct copies private object[] _items; #pragma warning restore IDE0044 private int _index; + private object? _current; internal Enumerator(EndpointMetadataCollection collection) { _items = collection._items; _index = 0; - Current = null; + _current = null; } /// /// Gets the element at the current position of the enumerator /// - public object? Current { get; private set; } + public object Current => _current!; /// /// Releases all resources used by the . @@ -200,11 +201,11 @@ public bool MoveNext() { if (_index < _items.Length) { - Current = _items[_index++]; + _current = _items[_index++]; return true; } - Current = null; + _current = null; return false; } @@ -214,7 +215,7 @@ public bool MoveNext() public void Reset() { _index = 0; - Current = null; + _current = null; } } } diff --git a/src/Http/Routing.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Routing.Abstractions/src/PublicAPI.Unshipped.txt index 8ea36345fe9c..5cad7f7ef8e2 100644 --- a/src/Http/Routing.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing.Abstractions/src/PublicAPI.Unshipped.txt @@ -1,2 +1,4 @@ #nullable enable -Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata() -> T! (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) \ No newline at end of file +*REMOVED*Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object? (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object! (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata() -> T! (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) diff --git a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs index 7af7e03f097d..058304686084 100644 --- a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs @@ -28,6 +28,35 @@ public static class EndpointRouteBuilderExtensions private static readonly string[] DeleteVerb = new[] { HttpMethods.Delete }; private static readonly string[] PatchVerb = new[] { HttpMethods.Patch }; + /// + /// Creates a for defining endpoints all prefixed with the specified . + /// + /// The to add the group to. + /// The pattern that prefixes all routes in this group. + /// + /// A that is both an and an . + /// The same builder can be used to add endpoints with the given , and to customize those endpoints using conventions. + /// + public static GroupRouteBuilder MapGroup(this IEndpointRouteBuilder endpoints, string prefix) => + endpoints.MapGroup(RoutePatternFactory.Parse(prefix ?? throw new ArgumentNullException(nameof(prefix)))); + + /// + /// Creates a for defining endpoints all prefixed with the specified . + /// + /// The to add the group to. + /// The pattern that prefixes all routes in this group. + /// + /// A that is both an and an . + /// The same builder can be used to add endpoints with the given , and to customize those endpoints using conventions. + /// + public static GroupRouteBuilder MapGroup(this IEndpointRouteBuilder endpoints, RoutePattern prefix) + { + ArgumentNullException.ThrowIfNull(endpoints, nameof(endpoints)); + ArgumentNullException.ThrowIfNull(prefix, nameof(prefix)); + + return new(endpoints, prefix); + } + /// /// Adds a to the that matches HTTP GET requests /// for the specified pattern. @@ -494,19 +523,18 @@ private static RouteHandlerBuilder Map( const int defaultOrder = 0; - var routeParams = new List(pattern.Parameters.Count); - foreach (var part in pattern.Parameters) + var fullPattern = pattern; + + if (endpoints is GroupRouteBuilder group) { - routeParams.Add(part.Name); + fullPattern = RoutePatternFactory.Combine(group.GroupPrefix, pattern); } - var routeHandlerOptions = endpoints.ServiceProvider?.GetService>(); - var builder = new RouteEndpointBuilder( pattern, defaultOrder) { - DisplayName = pattern.RawText ?? pattern.DebuggerToString(), + DisplayName = fullPattern.RawText ?? fullPattern.DebuggerToString(), ServiceProvider = endpoints.ServiceProvider, }; @@ -534,6 +562,13 @@ private static RouteHandlerBuilder Map( "The trimmer is unable to infer this on the nested lambda.")] void RouteHandlerBuilderConvention(EndpointBuilder endpointBuilder) { + var routeParams = new List(fullPattern.Parameters.Count); + foreach (var part in fullPattern.Parameters) + { + routeParams.Add(part.Name); + } + + var routeHandlerOptions = endpoints.ServiceProvider?.GetService>(); var options = new RequestDelegateFactoryOptions { ServiceProvider = endpoints.ServiceProvider, diff --git a/src/Http/Routing/src/GroupRouteBuilder.cs b/src/Http/Routing/src/GroupRouteBuilder.cs new file mode 100644 index 000000000000..054cdc8a0018 --- /dev/null +++ b/src/Http/Routing/src/GroupRouteBuilder.cs @@ -0,0 +1,140 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Routing; + +/// +/// A builder for defining groups of endpoints with a common prefix that implements both the +/// and interfaces. This can be used to add endpoints with the given , +/// and to customize those endpoints using conventions. +/// +public sealed class GroupRouteBuilder : IEndpointRouteBuilder, IEndpointConventionBuilder +{ + private readonly IEndpointRouteBuilder _outerEndpointRouteBuilder; + private readonly RoutePattern _pattern; + + private readonly List _dataSources = new(); + private readonly List> _conventions = new(); + + internal GroupRouteBuilder(IEndpointRouteBuilder outerEndpointRouteBuilder, RoutePattern pattern) + { + _outerEndpointRouteBuilder = outerEndpointRouteBuilder; + _pattern = pattern; + + if (outerEndpointRouteBuilder is GroupRouteBuilder outerGroup) + { + GroupPrefix = RoutePatternFactory.Combine(outerGroup.GroupPrefix, pattern); + } + else + { + GroupPrefix = pattern; + } + + _outerEndpointRouteBuilder.DataSources.Add(new GroupDataSource(this)); + } + + /// + /// The prefixing all endpoints defined using this . + /// This accounts for nested groups and gives the full group prefix, not just the prefix supplied to the last call to + /// . + /// + public RoutePattern GroupPrefix { get; } + + IServiceProvider IEndpointRouteBuilder.ServiceProvider => _outerEndpointRouteBuilder.ServiceProvider; + IApplicationBuilder IEndpointRouteBuilder.CreateApplicationBuilder() => _outerEndpointRouteBuilder.CreateApplicationBuilder(); + ICollection IEndpointRouteBuilder.DataSources => _dataSources; + void IEndpointConventionBuilder.Add(Action convention) => _conventions.Add(convention); + + private bool IsRoot => ReferenceEquals(GroupPrefix, _pattern); + + private sealed class GroupDataSource : EndpointDataSource + { + private readonly GroupRouteBuilder _groupRouteBuilder; + + public GroupDataSource(GroupRouteBuilder groupRouteBuilder) + { + _groupRouteBuilder = groupRouteBuilder; + } + + public override IReadOnlyList Endpoints + { + get + { + var list = new List(); + + foreach (var dataSource in _groupRouteBuilder._dataSources) + { + foreach (var endpoint in dataSource.Endpoints) + { + // Endpoint does not provide a RoutePattern but RouteEndpoint does. So it's impossible to apply a prefix for custom Endpoints. + // Supporting arbitrary Endpoints just to add group metadata would require changing the Endpoint type breaking any real scenario. + if (endpoint is not RouteEndpoint routeEndpoint) + { + throw new NotSupportedException(Resources.FormatMapGroup_CustomEndpointUnsupported(endpoint.GetType())); + } + + // Make the full route pattern visible to IEndpointConventionBuilder extension methods called on the group. + // This includes patterns from any parent groups. + var fullRoutePattern = RoutePatternFactory.Combine(_groupRouteBuilder.GroupPrefix, routeEndpoint.RoutePattern); + + // RequestDelegate can never be null on a RouteEndpoint. The nullability carries over from Endpoint. + var routeEndpointBuilder = new RouteEndpointBuilder(routeEndpoint.RequestDelegate!, fullRoutePattern, routeEndpoint.Order) + { + DisplayName = routeEndpoint.DisplayName, + ServiceProvider = _groupRouteBuilder._outerEndpointRouteBuilder.ServiceProvider, + }; + + // Apply group conventions to each endpoint in the group at a lower precedent than metadata already on the endpoint. + foreach (var convention in _groupRouteBuilder._conventions) + { + convention(routeEndpointBuilder); + } + + // If we supported mutating the route pattern via a group convention, RouteEndpointBuilder.RoutePattern would have + // to be the partialRoutePattern (below) instead of the fullRoutePattern (above) since that's all we can control. We cannot + // change a parent prefix. In order to allow to conventions to read the fullRoutePattern, we do not support mutation. + if (!ReferenceEquals(fullRoutePattern, routeEndpointBuilder.RoutePattern)) + { + throw new NotSupportedException(Resources.FormatMapGroup_ChangingRoutePatternUnsupported( + fullRoutePattern.RawText, routeEndpointBuilder.RoutePattern.RawText)); + } + + // Any metadata already on the RouteEndpoint must have been applied directly to the endpoint or to a nested group. + // This makes the metadata more specific than what's being applied to this group. So add it after this group's conventions. + // + // REVIEW: This means group conventions don't get visibility into endpoint-specific metadata nor the ability to override it. + // We should consider allowing group-aware conventions the ability to read and mutate this metadata in future releases. + foreach (var metadata in routeEndpoint.Metadata) + { + routeEndpointBuilder.Metadata.Add(metadata); + } + + // Use _pattern instead of GroupPrefix when we're calculating an intermediate RouteEndpoint. + var partialRoutePattern = _groupRouteBuilder.IsRoot + ? fullRoutePattern : RoutePatternFactory.Combine(_groupRouteBuilder._pattern, routeEndpoint.RoutePattern); + + // The RequestDelegate, Order and DisplayName can all be overridden by non-group-aware conventions. Unlike with metadata, + // if a convention is applied to a group that changes any of these, I would expect these to be overridden as there's no + // reasonable way to merge these properties. + list.Add(new RouteEndpoint( + // Again, RequestDelegate can never be null given a RouteEndpoint. + routeEndpointBuilder.RequestDelegate!, + partialRoutePattern, + routeEndpointBuilder.Order, + new(routeEndpointBuilder.Metadata), + routeEndpointBuilder.DisplayName)); + } + } + + return list; + } + } + + public override IChangeToken GetChangeToken() => new CompositeEndpointDataSource(_groupRouteBuilder._dataSources).GetChangeToken(); + } +} diff --git a/src/Http/Routing/src/Patterns/RoutePatternFactory.cs b/src/Http/Routing/src/Patterns/RoutePatternFactory.cs index 22123f60ac4f..1d1e8b13da56 100644 --- a/src/Http/Routing/src/Patterns/RoutePatternFactory.cs +++ b/src/Http/Routing/src/Patterns/RoutePatternFactory.cs @@ -1084,6 +1084,108 @@ public static RoutePatternParameterPolicyReference ParameterPolicy(string parame return ParameterPolicyCore(parameterPolicy); } + internal static RoutePattern Combine(RoutePattern left, RoutePattern right) + { + static IReadOnlyList CombineLists( + IReadOnlyList leftList, + IReadOnlyList rightList, + Func>? checkDuplicates = null, + string? rawText = null) + { + if (leftList.Count is 0) + { + return rightList; + } + if (rightList.Count is 0) + { + return leftList; + } + + var combinedCount = leftList.Count + rightList.Count; + var combinedList = new List(combinedCount); + // If checkDuplicates is set, so is rawText so the right exception can be thrown from check. + var check = checkDuplicates?.Invoke(combinedCount, rawText!); + foreach (var item in leftList) + { + check?.Invoke(item); + combinedList.Add(item); + } + foreach (var item in rightList) + { + check?.Invoke(item); + combinedList.Add(item); + } + return combinedList; + } + + // Technically, the ParameterPolicies could probably be merged because it's a list, but it makes little sense to add policy + // for the same parameter in both the left and right part of the combined pattern. Defaults and Required values cannot be + // merged because the `TValue` is `object?`, but over-setting a Default or RequiredValue (which may not be in the parameter list) + // seems okay as long as the values are the same for a given key in both the left and right pattern. There's already similar logic + // in PatternCore for when defaults come from both the `defaults` and `segments` param. `requiredValues` cannot be defined in + // `segments` so there's no equivalent to merging these until now. + static IReadOnlyDictionary CombineDictionaries( + IReadOnlyDictionary leftDictionary, + IReadOnlyDictionary rightDictionary, + string rawText, + string dictionaryName) + { + if (leftDictionary.Count is 0) + { + return rightDictionary; + } + if (rightDictionary.Count is 0) + { + return leftDictionary; + } + + var combinedDictionary = new Dictionary(leftDictionary.Count + rightDictionary.Count, StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in leftDictionary) + { + combinedDictionary.Add(key, value); + } + foreach (var (key, value) in rightDictionary) + { + if (combinedDictionary.TryGetValue(key, out var leftValue)) + { + if (!Equals(leftValue, value)) + { + throw new InvalidOperationException(Resources.FormatMapGroup_RepeatedDictionaryEntry(rawText, dictionaryName, key)); + } + } + else + { + combinedDictionary.Add(key, value); + } + } + return combinedDictionary; + } + + static Action CheckDuplicateParameters(int parameterCount, string rawText) + { + var parameterNameSet = new HashSet(parameterCount, StringComparer.OrdinalIgnoreCase); + return parameterPart => + { + if (!parameterNameSet.Add(parameterPart.Name)) + { + var errorText = Resources.FormatTemplateRoute_RepeatedParameter(parameterPart.Name); + throw new RoutePatternException(rawText, errorText); + } + }; + } + + var rawText = $"{left.RawText?.TrimEnd('/')}/{right.RawText?.TrimStart('/')}"; + + var parameters = CombineLists(left.Parameters, right.Parameters, CheckDuplicateParameters, rawText); + var pathSegments = CombineLists(left.PathSegments, right.PathSegments); + + var defaults = CombineDictionaries(left.Defaults, right.Defaults, rawText, nameof(RoutePattern.Defaults)); + var requiredValues = CombineDictionaries(left.RequiredValues, right.RequiredValues, rawText, nameof(RoutePattern.RequiredValues)); + var parameterPolicies = CombineDictionaries(left.ParameterPolicies, right.ParameterPolicies, rawText, nameof(RoutePattern.ParameterPolicies)); + + return new RoutePattern(rawText, defaults, parameterPolicies, requiredValues, parameters, pathSegments); + } + private static RoutePatternParameterPolicyReference ParameterPolicyCore(string parameterPolicy) { return new RoutePatternParameterPolicyReference(parameterPolicy); diff --git a/src/Http/Routing/src/PublicAPI.Unshipped.txt b/src/Http/Routing/src/PublicAPI.Unshipped.txt index 3d9ca9b5f48f..632d25fbd8af 100644 --- a/src/Http/Routing/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing/src/PublicAPI.Unshipped.txt @@ -1,7 +1,11 @@ #nullable enable Microsoft.AspNetCore.Http.RouteHandlerFilterExtensions +Microsoft.AspNetCore.Routing.GroupRouteBuilder +Microsoft.AspNetCore.Routing.GroupRouteBuilder.GroupPrefix.get -> Microsoft.AspNetCore.Routing.Patterns.RoutePattern! Microsoft.AspNetCore.Routing.RouteOptions.SetParameterPolicy(string! token, System.Type! type) -> void Microsoft.AspNetCore.Routing.RouteOptions.SetParameterPolicy(string! token) -> void +static Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapGroup(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, Microsoft.AspNetCore.Routing.Patterns.RoutePattern! prefix) -> Microsoft.AspNetCore.Routing.GroupRouteBuilder! +static Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapGroup(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! prefix) -> Microsoft.AspNetCore.Routing.GroupRouteBuilder! static Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapPatch(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Delegate! handler) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! static Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapPatch(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, Microsoft.AspNetCore.Http.RequestDelegate! requestDelegate) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! override Microsoft.AspNetCore.Routing.RouteValuesAddress.ToString() -> string? diff --git a/src/Http/Routing/src/Resources.resx b/src/Http/Routing/src/Resources.resx index 3bed95c80f70..763fc974d0d0 100644 --- a/src/Http/Routing/src/Resources.resx +++ b/src/Http/Routing/src/Resources.resx @@ -234,4 +234,13 @@ No media type found for format '{0}'. + + MapGroup does not support mutating RouteEndpointBuilder.RoutePattern from '{0}' to '{1}' via conventions. + + + MapGroup does not support custom Endpoint type '{0}'. Only RouteEndpoints can be grouped. + + + MapGroup cannot build a pattern for '{0}' because the 'RoutePattern.{1}' dictionary key '{2}' has multiple values. + \ No newline at end of file diff --git a/src/Http/Routing/test/UnitTests/Builder/GroupTest.cs b/src/Http/Routing/test/UnitTests/Builder/GroupTest.cs new file mode 100644 index 000000000000..1d77e8d47896 --- /dev/null +++ b/src/Http/Routing/test/UnitTests/Builder/GroupTest.cs @@ -0,0 +1,409 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.AspNetCore.Routing.TestObjects; +using Microsoft.Extensions.Primitives; +using Moq; + +namespace Microsoft.AspNetCore.Builder; + +public class GroupTest +{ + private EndpointDataSource GetEndpointDataSource(IEndpointRouteBuilder endpointRouteBuilder) + { + return Assert.IsAssignableFrom(Assert.Single(endpointRouteBuilder.DataSources)); + } + + [Fact] + public async Task Prefix_CanBeEmpty() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); + + var group = builder.MapGroup(""); + Assert.Equal("", group.GroupPrefix.RawText); + + group.MapGet("/{id}", (int id, HttpContext httpContext) => + { + httpContext.Items["id"] = id; + }); + + var dataSource = GetEndpointDataSource(builder); + + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + var routeEndpoint = Assert.IsType(endpoint); + + var methodMetadata = endpoint.Metadata.GetMetadata(); + Assert.NotNull(methodMetadata); + var method = Assert.Single(methodMetadata!.HttpMethods); + Assert.Equal("GET", method); + + Assert.Equal("HTTP: GET /{id}", endpoint.DisplayName); + Assert.Equal("/{id}", routeEndpoint.RoutePattern.RawText); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues["id"] = "42"; + + await endpoint.RequestDelegate!(httpContext); + + Assert.Equal(42, httpContext.Items["id"]); + } + + [Fact] + public async Task PrefixWithRouteParameter_CanBeUsed() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); + + var group = builder.MapGroup("/{org}"); + Assert.Equal("/{org}", group.GroupPrefix.RawText); + + group.MapGet("/{id}", (string org, int id, HttpContext httpContext) => + { + httpContext.Items["org"] = org; + httpContext.Items["id"] = id; + }); + + var dataSource = GetEndpointDataSource(builder); + + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + var routeEndpoint = Assert.IsType(endpoint); + + var methodMetadata = endpoint.Metadata.GetMetadata(); + Assert.NotNull(methodMetadata); + var method = Assert.Single(methodMetadata!.HttpMethods); + Assert.Equal("GET", method); + + Assert.Equal("HTTP: GET /{org}/{id}", endpoint.DisplayName); + Assert.Equal("/{org}/{id}", routeEndpoint.RoutePattern.RawText); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues["org"] = "dotnet"; + httpContext.Request.RouteValues["id"] = "42"; + + await endpoint.RequestDelegate!(httpContext); + + Assert.Equal("dotnet", httpContext.Items["org"]); + Assert.Equal(42, httpContext.Items["id"]); + } + + [Fact] + public async Task NestedPrefixWithRouteParameters_CanBeUsed() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); + + var group = builder.MapGroup("/{org}").MapGroup("/{id}"); + Assert.Equal("/{org}/{id}", group.GroupPrefix.RawText); + + group.MapGet("/", (string org, int id, HttpContext httpContext) => + { + httpContext.Items["org"] = org; + httpContext.Items["id"] = id; + }); + + var dataSource = GetEndpointDataSource(builder); + + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + var routeEndpoint = Assert.IsType(endpoint); + + var methodMetadata = endpoint.Metadata.GetMetadata(); + Assert.NotNull(methodMetadata); + var method = Assert.Single(methodMetadata!.HttpMethods); + Assert.Equal("GET", method); + + Assert.Equal("HTTP: GET /{org}/{id}/", endpoint.DisplayName); + Assert.Equal("/{org}/{id}/", routeEndpoint.RoutePattern.RawText); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues["org"] = "dotnet"; + httpContext.Request.RouteValues["id"] = "42"; + + await endpoint.RequestDelegate!(httpContext); + + Assert.Equal("dotnet", httpContext.Items["org"]); + Assert.Equal(42, httpContext.Items["id"]); + } + + [Fact] + public void RepeatedRouteParameter_ThrowsRoutePatternException() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); + + var ex = Assert.Throws(() => builder.MapGroup("/{ID}").MapGroup("/{id}")); + + Assert.Equal("/{ID}/{id}", ex.Pattern); + Assert.Equal("The route parameter name 'id' appears more than one time in the route template.", ex.Message); + } + + [Fact] + public void NullParameters_ThrowsArgumentNullException() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); + + var ex = Assert.Throws(() => builder.MapGroup((string)null!)); + Assert.Equal("prefix", ex.ParamName); + ex = Assert.Throws(() => builder.MapGroup((RoutePattern)null!)); + Assert.Equal("prefix", ex.ParamName); + + builder = null; + + ex = Assert.Throws(() => builder!.MapGroup(RoutePatternFactory.Parse("/"))); + Assert.Equal("endpoints", ex.ParamName); + ex = Assert.Throws(() => builder!.MapGroup("/")); + Assert.Equal("endpoints", ex.ParamName); + } + + [Fact] + public void RoutePatternInConvention_IncludesFullGroupPrefix() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); + + var outer = builder.MapGroup("/outer"); + var inner = outer.MapGroup("/inner"); + inner.MapGet("/foo", () => "Hello World!"); + + RoutePattern? outerPattern = null; + RoutePattern? innerPattern = null; + + ((IEndpointConventionBuilder)outer).Add(builder => + { + outerPattern = ((RouteEndpointBuilder)builder).RoutePattern; + }); + ((IEndpointConventionBuilder)inner).Add(builder => + { + innerPattern = ((RouteEndpointBuilder)builder).RoutePattern; + }); + + var dataSource = GetEndpointDataSource(builder); + Assert.Single(dataSource.Endpoints); + + Assert.Equal("/outer/inner/foo", outerPattern?.RawText); + Assert.Equal("/outer/inner/foo", innerPattern?.RawText); + } + + [Fact] + public void ServiceProviderInConvention_IsSet() + { + var serviceProvider = Mock.Of(); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + + var group = builder.MapGroup("/group"); + group.MapGet("/foo", () => "Hello World!"); + + IServiceProvider? endpointBuilderServiceProvider = null; + + ((IEndpointConventionBuilder)group).Add(builder => + { + endpointBuilderServiceProvider = builder.ServiceProvider; + }); + + var dataSource = GetEndpointDataSource(builder); + Assert.Single(dataSource.Endpoints); + + Assert.Same(serviceProvider, endpointBuilderServiceProvider); + } + + [Fact] + public async Task BuildingEndpointInConvention_Works() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); + + var group = builder.MapGroup("/group"); + var mapGetCalled = false; + + group.MapGet("/", () => + { + mapGetCalled = true; + }); + + Endpoint? conventionBuiltEndpoint = null; + + ((IEndpointConventionBuilder)group).Add(builder => + { + conventionBuiltEndpoint = builder.Build(); + }); + + var dataSource = GetEndpointDataSource(builder); + + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + + var httpContext = new DefaultHttpContext(); + + Assert.NotNull(conventionBuiltEndpoint); + Assert.False(mapGetCalled); + await conventionBuiltEndpoint!.RequestDelegate!(httpContext); + Assert.True(mapGetCalled); + + mapGetCalled = false; + await endpoint.RequestDelegate!(httpContext); + Assert.True(mapGetCalled); + } + + [Fact] + public void ModifyingRoutePatternInConvention_ThrowsNotSupportedException() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); + + var group = builder.MapGroup("/group"); + group.MapGet("/foo", () => "Hello World!"); + + ((IEndpointConventionBuilder)group).Add(builder => + { + ((RouteEndpointBuilder)builder).RoutePattern = RoutePatternFactory.Parse("/bar"); + }); + + var dataSource = GetEndpointDataSource(builder); + var ex = Assert.Throws(() => dataSource.Endpoints); + Assert.Equal("MapGroup does not support mutating RouteEndpointBuilder.RoutePattern from '/group/foo' to '/bar' via conventions.", ex.Message); + } + + [Fact] + public async Task ChangingMostEndpointBuilderPropertiesInConvention_Works() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); + + var group = builder.MapGroup("/group"); + var mapGetCalled = false; + var replacementCalled = false; + + group.MapGet("/", () => + { + mapGetCalled = true; + }); + + ((IEndpointConventionBuilder)group).Add(builder => + { + builder.DisplayName = "Replaced!"; + builder.RequestDelegate = ctx => + { + replacementCalled = true; + return Task.CompletedTask; + }; + + ((RouteEndpointBuilder)builder).Order = 42; + }); + + var dataSource = GetEndpointDataSource(builder); + + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + + var httpContext = new DefaultHttpContext(); + + await endpoint!.RequestDelegate!(httpContext); + + Assert.False(mapGetCalled); + Assert.True(replacementCalled); + Assert.Equal("Replaced!", endpoint.DisplayName); + + var routeEndpoint = Assert.IsType(endpoint); + Assert.Equal(42, routeEndpoint.Order); + } + + [Fact] + public void GivenNonRouteEndpoint_ThrowsNotSupportedException() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); + + var group = builder.MapGroup("/group"); + ((IEndpointRouteBuilder)group).DataSources.Add(new TestCustomEndpintDataSource()); + + var dataSource = GetEndpointDataSource(builder); + var ex = Assert.Throws(() => dataSource.Endpoints); + Assert.Equal( + "MapGroup does not support custom Endpoint type 'Microsoft.AspNetCore.Builder.GroupTest+TestCustomEndpoint'. " + + "Only RouteEndpoints can be grouped.", + ex.Message); + } + + [Fact] + public void OuterGroupMetadata_AddedFirst() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); + + var outer = builder.MapGroup("/outer"); + var inner = outer.MapGroup("/inner"); + inner.MapGet("/foo", () => "Hello World!").WithMetadata("/foo"); + + inner.WithMetadata("/inner"); + outer.WithMetadata("/outer"); + + var dataSource = GetEndpointDataSource(builder); + var endpoint = Assert.Single(dataSource.Endpoints); + + Assert.True(endpoint.Metadata.Count >= 3); + Assert.Equal("/outer", endpoint.Metadata[0]); + Assert.Equal("/inner", endpoint.Metadata[1]); + Assert.Equal("/foo", endpoint.Metadata[^1]); + } + + [Fact] + public void MultipleEndpoints_AreSupported() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); + + var group = builder.MapGroup("/group"); + group.MapGet("/foo", () => "foo"); + group.MapGet("/bar", () => "bar"); + + group.WithMetadata("/group"); + + var dataSource = GetEndpointDataSource(builder); + Assert.Collection(dataSource.Endpoints.OfType(), + routeEndpoint => + { + Assert.Equal("/group/foo", routeEndpoint.RoutePattern.RawText); + Assert.True(routeEndpoint.Metadata.Count >= 1); + Assert.Equal("/group", routeEndpoint.Metadata[0]); + }, + routeEndpoint => + { + Assert.Equal("/group/bar", routeEndpoint.RoutePattern.RawText); + Assert.True(routeEndpoint.Metadata.Count >= 1); + Assert.Equal("/group", routeEndpoint.Metadata[0]); + }); + } + + [Fact] + public void DataSourceFiresChangeToken_WhenInnerDataSourceFiresChangeToken() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); + var dynamicDataSource = new DynamicEndpointDataSource(); + + var group = builder.MapGroup("/group"); + ((IEndpointRouteBuilder)group).DataSources.Add(dynamicDataSource); + + var groupDataSource = GetEndpointDataSource(builder); + + var groupChangeToken = groupDataSource.GetChangeToken(); + Assert.False(groupChangeToken.HasChanged); + + dynamicDataSource.AddEndpoint(new RouteEndpoint( + TestConstants.EmptyRequestDelegate, + RoutePatternFactory.Parse("/foo"), + 0, null, null)); + + Assert.True(groupChangeToken.HasChanged); + + var prefixedEndpoint = Assert.IsType(Assert.Single(groupDataSource.Endpoints)); + Assert.Equal("/group/foo", prefixedEndpoint.RoutePattern.RawText); + } + + private sealed class TestCustomEndpoint : Endpoint + { + public TestCustomEndpoint() : base(null, null, null) { } + } + + private sealed class TestCustomEndpintDataSource : EndpointDataSource + { + public override IReadOnlyList Endpoints => new[] { new TestCustomEndpoint() }; + public override IChangeToken GetChangeToken() => throw new NotImplementedException(); + } +} diff --git a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs index 8f885095fcbd..09e0e7e87392 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs @@ -129,7 +129,7 @@ public void MapPatch_BuildsEndpointWithCorrectMethod() } [Fact] - public async Task MapGetWithRouteParameter_BuildsEndpointWithRouteSpecificBinding() + public async Task MapGet_WithRouteParameter_BuildsEndpointWithRouteSpecificBinding() { var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); _ = builder.MapGet("/{id}", (int? id, HttpContext httpContext) => @@ -167,7 +167,7 @@ public async Task MapGetWithRouteParameter_BuildsEndpointWithRouteSpecificBindin } [Fact] - public async Task MapGetWithoutRouteParameter_BuildsEndpointWithQuerySpecificBinding() + public async Task MapGet_WithoutRouteParameter_BuildsEndpointWithQuerySpecificBinding() { var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); _ = builder.MapGet("/", (int? id, HttpContext httpContext) => diff --git a/src/Http/Routing/test/UnitTests/CompositeEndpointDataSourceTest.cs b/src/Http/Routing/test/UnitTests/CompositeEndpointDataSourceTest.cs index ad7bee63ce10..7bf5401dee70 100644 --- a/src/Http/Routing/test/UnitTests/CompositeEndpointDataSourceTest.cs +++ b/src/Http/Routing/test/UnitTests/CompositeEndpointDataSourceTest.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Http; @@ -154,19 +154,4 @@ private RouteEndpoint CreateEndpoint( EndpointMetadataCollection.Empty, null); } - - private class CustomEndpointDataSource : EndpointDataSource - { - private readonly CancellationTokenSource _cts; - private readonly CancellationChangeToken _token; - - public CustomEndpointDataSource() - { - _cts = new CancellationTokenSource(); - _token = new CancellationChangeToken(_cts.Token); - } - - public override IChangeToken GetChangeToken() => _token; - public override IReadOnlyList Endpoints => Array.Empty(); - } } diff --git a/src/Http/Routing/test/UnitTests/Patterns/RoutePatternFactoryTest.cs b/src/Http/Routing/test/UnitTests/Patterns/RoutePatternFactoryTest.cs index d454299d257d..d2e4bb1470a4 100644 --- a/src/Http/Routing/test/UnitTests/Patterns/RoutePatternFactoryTest.cs +++ b/src/Http/Routing/test/UnitTests/Patterns/RoutePatternFactoryTest.cs @@ -713,4 +713,133 @@ public void Segment_ArrayOfParts() Assert.Same(paramPartC, actual.Parts[1]); Assert.Same(paramPartD, actual.Parts[2]); } + + [Theory] + [InlineData("/a/b", "")] + [InlineData("/a/b", "/")] + [InlineData("/a/b/", "")] + [InlineData("/a/b/", "/")] + [InlineData("/a", "b/")] + [InlineData("/a/", "b/")] + [InlineData("/a", "/b/")] + [InlineData("/a/", "/b/")] + [InlineData("/", "a/b/")] + [InlineData("/", "/a/b/")] + [InlineData("", "a/b/")] + [InlineData("", "/a/b/")] + public void Combine_HandlesEmptyPatternsAndDuplicateSeperatorsInRawText(string leftTemplate, string rightTemplate) + { + var left = RoutePatternFactory.Parse(leftTemplate); + var right = RoutePatternFactory.Parse(rightTemplate); + + var combined = RoutePatternFactory.Combine(left, right); + + static Action AssertLiteral(string literal) + { + return segment => + { + var part = Assert.IsType(Assert.Single(segment.Parts)); + Assert.Equal(literal, part.Content); + }; + } + + Assert.Equal("/a/b/", combined.RawText); + Assert.Collection(combined.PathSegments, AssertLiteral("a"), AssertLiteral("b")); + } + + [Fact] + public void Combine_WithDuplicateParameters_Throws() + { + // Parameter names are case insensitive + var left = RoutePatternFactory.Parse("/{id}"); + var right = RoutePatternFactory.Parse("/{ID}"); + + var ex = Assert.Throws(() => RoutePatternFactory.Combine(left, right)); + + Assert.Equal("/{id}/{ID}", ex.Pattern); + Assert.Equal("The route parameter name 'ID' appears more than one time in the route template.", ex.Message); + } + + [Fact] + public void Combine_WithDuplicateDefaults_DoesNotThrow() + { + // Keys should be case insensitive. + var left = RoutePatternFactory.Parse("/a/{x=foo}"); + var right = RoutePatternFactory.Parse("/b", defaults: new { X = "foo" }, parameterPolicies: null); + + var combined = RoutePatternFactory.Combine(left, right); + + Assert.Equal("/a/{x=foo}/b", combined.RawText); + + var (key, value) = Assert.Single(combined.Defaults); + Assert.Equal("x", key); + Assert.Equal("foo", value); + } + + [Fact] + public void Combine_WithDuplicateRequiredValues_DoesNotThrow() + { + // Keys should be case insensitive. + // The required value must be a parameter or a default to parse. Since we cannot repeat parameters, we set defaults instead. + var left = RoutePatternFactory.Parse("/a", defaults: new { x = "foo" }, parameterPolicies: null, requiredValues: new { x = "foo" }); + var right = RoutePatternFactory.Parse("/b", defaults: new { X = "foo" }, parameterPolicies: null, requiredValues: new { X = "foo" }); + + var combined = RoutePatternFactory.Combine(left, right); + + Assert.Equal("/a/b", combined.RawText); + + var (key, value) = Assert.Single(combined.RequiredValues); + Assert.Equal("x", key); + Assert.Equal("foo", value); + } + + [Fact] + public void Combine_WithDuplicateParameterPolicies_Throws() + { + // Since even the exact same instance and keys throw, we don't bother testing different key casing. + var policies = new { X = new RegexRouteConstraint("x"), }; + + var left = RoutePatternFactory.Parse("/a", defaults: null, parameterPolicies: policies); + var right = RoutePatternFactory.Parse("/b", defaults: null, parameterPolicies: policies); + + var ex = Assert.Throws(() => RoutePatternFactory.Combine(left, right)); + Assert.Equal("MapGroup cannot build a pattern for '/a/b' because the 'RoutePattern.ParameterPolicies' dictionary key 'X' has multiple values.", ex.Message); + } + + [Fact] + public void Combine_WithConflictingDefaults_Throws() + { + // Keys should be case insensitive but not values. + // Value differs in casing. As long as object.Equals(object?, object?) returns false, there's a conflict. + var left = RoutePatternFactory.Parse("/a/{x=foo}"); + var right = RoutePatternFactory.Parse("/b", defaults: new { X = "Foo" }, parameterPolicies: null); + + var ex = Assert.Throws(() => RoutePatternFactory.Combine(left, right)); + Assert.Equal("MapGroup cannot build a pattern for '/a/{x=foo}/b' because the 'RoutePattern.Defaults' dictionary key 'X' has multiple values.", ex.Message); + } + + [Fact] + public void Combine_WithConflictingRequiredValues_Throws() + { + // Keys should be case insensitive but not values. + // Value differs in casing. As long as object.Equals(object?, object?) returns false, there's a conflict. + // The required value must be a parameter or a default to parse. Since we cannot repeat parameters, we set defaults instead. + var left = RoutePatternFactory.Parse("/a", defaults: new { x = "foo" }, parameterPolicies: null, requiredValues: new { x = "foo" }); + var right = RoutePatternFactory.Parse("/b", defaults: new { X = "foo" }, parameterPolicies: null, requiredValues: new { X = "Foo" }); + + var ex = Assert.Throws(() => RoutePatternFactory.Combine(left, right)); + Assert.Equal("MapGroup cannot build a pattern for '/a/b' because the 'RoutePattern.RequiredValues' dictionary key 'X' has multiple values.", ex.Message); + } + + [Fact] + public void Combine_WithConflictingParameterPolicies_Throws() + { + // Even the exact same policy instance throws, but this verifies theres a conflict even if the policy is defined via a pattern in one part. + // The policy is defined via a pattern in bot parts because parameters cannot be repeated. + var left = RoutePatternFactory.Parse("/a/{x:string}"); + var right = RoutePatternFactory.Parse("/b", defaults: null, parameterPolicies: new { X = new RegexRouteConstraint("foo") }); + + var ex = Assert.Throws(() => RoutePatternFactory.Combine(left, right)); + Assert.Equal("MapGroup cannot build a pattern for '/a/{x:string}/b' because the 'RoutePattern.ParameterPolicies' dictionary key 'X' has multiple values.", ex.Message); + } } diff --git a/src/Http/samples/MinimalSample/MinimalSample.csproj b/src/Http/samples/MinimalSample/MinimalSample.csproj index eea90b96520b..4a160b62569e 100644 --- a/src/Http/samples/MinimalSample/MinimalSample.csproj +++ b/src/Http/samples/MinimalSample/MinimalSample.csproj @@ -12,7 +12,6 @@ - diff --git a/src/Http/samples/MinimalSample/Program.cs b/src/Http/samples/MinimalSample/Program.cs index f2f50d8c274c..4eb5ecd34d41 100644 --- a/src/Http/samples/MinimalSample/Program.cs +++ b/src/Http/samples/MinimalSample/Program.cs @@ -16,8 +16,18 @@ string Plaintext() => "Hello, World!"; app.MapGet("/plaintext", Plaintext); +var nestedGroup = app.MapGroup("/group/{groupName}") + .MapGroup("/nested/{nestedName}") + .WithMetadata(new TagsAttribute("nested")); + +nestedGroup + .MapGet("/", (string groupName, string nestedName) => + { + return $"Hello from {groupName}:{nestedName}!"; + }); + object Json() => new { message = "Hello, World!" }; -app.MapGet("/json", Json); +app.MapGet("/json", Json).WithTags("json"); string SayHello(string name) => $"Hello, {name}!"; app.MapGet("/hello/{name}", SayHello); diff --git a/src/Mvc/samples/MvcSandbox/Startup.cs b/src/Mvc/samples/MvcSandbox/Startup.cs index 27910a3b0b4f..2708a0c0b47c 100644 --- a/src/Mvc/samples/MvcSandbox/Startup.cs +++ b/src/Mvc/samples/MvcSandbox/Startup.cs @@ -19,11 +19,24 @@ public void Configure(IApplicationBuilder app) app.UseStaticFiles(); app.UseRouting(); + + static void ConfigureEndpoints(IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/MapGet", () => "MapGet"); + + endpoints.MapControllers(); + endpoints.MapControllerRoute( + Guid.NewGuid().ToString(), + "{controller=Home}/{action=Index}/{id?}"); + + endpoints.MapRazorPages(); + } + app.UseEndpoints(builder => { - builder.MapControllers(); - builder.MapDefaultControllerRoute(); - builder.MapRazorPages(); + ConfigureEndpoints(builder); + var group = builder.MapGroup("/group"); + ConfigureEndpoints(group); }); } diff --git a/src/Mvc/test/Mvc.FunctionalTests/RoutingGroupsTest.cs b/src/Mvc/test/Mvc.FunctionalTests/RoutingGroupsTest.cs new file mode 100644 index 000000000000..29bae6189d2c --- /dev/null +++ b/src/Mvc/test/Mvc.FunctionalTests/RoutingGroupsTest.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using RoutingWebSite; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests; + +public class RoutingGroupsTests : IClassFixture> +{ + public RoutingGroupsTests(MvcTestFixture fixture) + { + Factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + } + + private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => builder.UseStartup(); + + public WebApplicationFactory Factory { get; } + + [Fact] + public async Task MatchesControllerGroup() + { + using var client = Factory.CreateClient(); + + var response = await client.GetAsync("controllers/contoso/Blog/ShowPosts"); + var content = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(content.RouteValues.TryGetValue("org", out var org)); + Assert.Equal("contoso", org); + } + + [Fact] + public async Task MatchesPagesGroupAndGeneratesValidLink() + { + using var client = Factory.CreateClient(); + + var response = await client.GetAsync("pages/PageWithLinks"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var document = await response.GetHtmlDocumentAsync(); + var editLink = document.RequiredQuerySelector("#editlink"); + Assert.Equal("/pages/Edit/10", editLink.GetAttribute("href")); + // TODO: Investigate why the #contactlink to the controller is empty. + } + + private record RouteInfo(string RouteName, IDictionary RouteValues, string Link); +} diff --git a/src/Mvc/test/WebSites/RoutingWebSite/StartupForGroups.cs b/src/Mvc/test/WebSites/RoutingWebSite/StartupForGroups.cs new file mode 100644 index 000000000000..24eb14cb90cc --- /dev/null +++ b/src/Mvc/test/WebSites/RoutingWebSite/StartupForGroups.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace RoutingWebSite; + +public class StartupForGroups +{ + // Set up application services + public void ConfigureServices(IServiceCollection services) + { + services.AddMvc().AddNewtonsoftJson(); + + // Used by some controllers defined in this project. + services.Configure(options => options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer)); + services.AddScoped(); + // This is used by test response generator + services.AddSingleton(); + } + + public virtual void Configure(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + var pagesGroup = endpoints.MapGroup("/pages"); + pagesGroup.MapRazorPages(); + + var controllerGroup = endpoints.MapGroup("/controllers/{org}"); + controllerGroup.MapControllers(); + }); + } +}