From fd65e8e94e6a38dea23a6113142d6c9f0abc1976 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 19 Apr 2022 14:58:32 -0700 Subject: [PATCH 01/15] Fix EndpointMetadataCollection nullability --- .../Http.Abstractions/src/PublicAPI.Unshipped.txt | 1 + .../src/Routing/EndpointMetadataCollection.cs | 13 +++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 4532909a61d7..a65c374aa1d7 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -2,6 +2,7 @@ *REMOVED*abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string! 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; } } } From 5876854c2dd8b9cee7f2e35a12486a5d10934d0e Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 19 Apr 2022 15:00:39 -0700 Subject: [PATCH 02/15] Add MapGroup --- .../Builder/EndpointRouteBuilderExtensions.cs | 53 +++- src/Http/Routing/src/GroupRouteBuilder.cs | 139 ++++++++++ .../src/Patterns/RoutePatternFactory.cs | 102 ++++++++ src/Http/Routing/src/PublicAPI.Unshipped.txt | 4 + src/Http/Routing/src/Resources.resx | 9 + ...ndlerEndpointRouteBuilderExtensionsTest.cs | 246 +++++++++++++++++- .../Patterns/RoutePatternFactoryTest.cs | 129 +++++++++ .../MinimalSample/MinimalSample.csproj | 1 - src/Http/samples/MinimalSample/Program.cs | 12 +- src/Mvc/samples/MvcSandbox/Startup.cs | 19 +- 10 files changed, 701 insertions(+), 13 deletions(-) create mode 100644 src/Http/Routing/src/GroupRouteBuilder.cs diff --git a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs index 7af7e03f097d..ce22deb7392e 100644 --- a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs @@ -28,6 +28,41 @@ public static class EndpointRouteBuilderExtensions private static readonly string[] DeleteVerb = new[] { HttpMethods.Delete }; private static readonly string[] PatchVerb = new[] { HttpMethods.Patch }; + /// + /// Create 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 a and a . + /// 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 prefixPattern) => + endpoints.MapGroup(RoutePatternFactory.Parse(prefixPattern ?? throw new ArgumentNullException(nameof(prefixPattern)))); + + /// + /// Create 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 a and a . + /// 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 prefixPattern) + { + if (endpoints is null) + { + throw new ArgumentNullException(nameof(endpoints)); + } + if (prefixPattern is null) + { + throw new ArgumentNullException(nameof(prefixPattern)); + } + + return new(endpoints, prefixPattern); + } + /// /// Adds a to the that matches HTTP GET requests /// for the specified pattern. @@ -494,19 +529,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 +568,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..003897e0bc1f --- /dev/null +++ b/src/Http/Routing/src/GroupRouteBuilder.cs @@ -0,0 +1,139 @@ +// 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 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, + }; + + // Apply group conventions to each endpoint in the group at a lower precedent than Metadata already. + 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..9e32f8df4383 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 `segements` param. `requiredValues` cannot be defined in + // `segements` 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..f013257ec199 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! prefixPattern) -> Microsoft.AspNetCore.Routing.GroupRouteBuilder! +static Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapGroup(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! prefixPattern) -> 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/RouteHandlerEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs index 8f885095fcbd..49dc0f9c31da 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -21,6 +22,11 @@ private ModelEndpointDataSource GetBuilderEndpointDataSource(IEndpointRouteBuild return Assert.IsType(Assert.Single(endpointRouteBuilder.DataSources)); } + private EndpointDataSource GetEndpointDataSource(IEndpointRouteBuilder endpointRouteBuilder) + { + return Assert.IsAssignableFrom(Assert.Single(endpointRouteBuilder.DataSources)); + } + private RouteEndpointBuilder GetRouteEndpointBuilder(IEndpointRouteBuilder endpointRouteBuilder) { return Assert.IsType(Assert.Single(GetBuilderEndpointDataSource(endpointRouteBuilder).EndpointBuilders)); @@ -129,7 +135,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 +173,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) => @@ -972,6 +978,231 @@ public async Task RequestDelegateFactory_CanInvokeEndpointFilter_ThatAccessesSer Assert.Equal("loggerErrorIsEnabled: True, parentName: RouteHandlerEndpointRouteBuilderExtensionsTest", body); } + [Fact] + public async Task MapGroup_WithRouteParameter_CanUseParameter() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); + + 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 MapGroup_NestedWithRouteParameters_CanUseParameters() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); + + 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 MapGroup_WithRepeatedRouteParameter_ThrowsRoutePatternException() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); + + 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 MapGroup_WithNullParameters_ThrowsArgumentNullException() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); + + var ex = Assert.Throws(() => builder.MapGroup((string)null!)); + Assert.Equal("prefixPattern", ex.ParamName); + ex = Assert.Throws(() => builder.MapGroup((RoutePattern)null!)); + Assert.Equal("prefixPattern", 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 MapGroup_RoutePatternInConvention_IncludesFullGroupPrefix() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); + + 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 async Task MapGroup_BuildingEndpointInConvention_Works() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); + + 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 MapGroup_ModifyingRoutePatternInConvention_ThrowsNotSupportedException() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); + + 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 void MapGroup_GivenNonRouteEndpoint_ThrowsNotSupportedException() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); + + 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.RouteHandlerEndpointRouteBuilderExtensionsTest+TestCustomEndpoint'. " + + "Only RouteEndpoints can be grouped.", + ex.Message); + } + + [Fact] + public void MapGroup_AddsOutermostGroupMetadataFirst() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); + + 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]); + } + class ServiceAccessingRouteHandlerFilter : IRouteHandlerFilter { private ILogger _logger; @@ -1091,4 +1322,15 @@ public void Dispose() return null; } } + + 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/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); }); } From ce53e6cab07e5f7e111d0acf04639552d53b2ac5 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 19 Apr 2022 15:42:16 -0700 Subject: [PATCH 03/15] *REMOVED* --- src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index a65c374aa1d7..20d456087930 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -1,5 +1,6 @@ #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! From b6caac570c4d989366c577efe80014e338f09a09 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 19 Apr 2022 15:55:48 -0700 Subject: [PATCH 04/15] Add test --- ...ndlerEndpointRouteBuilderExtensionsTest.cs | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs index 49dc0f9c31da..945fdba0d939 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs @@ -979,7 +979,42 @@ public async Task RequestDelegateFactory_CanInvokeEndpointFilter_ThatAccessesSer } [Fact] - public async Task MapGroup_WithRouteParameter_CanUseParameter() + public async Task MapGroup_Prefix_CanBeEmpty() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); + + 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 MapGroup_PrefixWithRouteParameter_CanBeUsed() { var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); @@ -1017,7 +1052,7 @@ public async Task MapGroup_WithRouteParameter_CanUseParameter() } [Fact] - public async Task MapGroup_NestedWithRouteParameters_CanUseParameters() + public async Task MapGroup_NestedPrefixWithRouteParameters_CanBeUsed() { var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); From 89ae694ee1c1bf874c7818ed2803f4a0a4dfaf88 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 19 Apr 2022 17:42:25 -0700 Subject: [PATCH 05/15] Fix PublicAPI for EndpointMetadataCollection type forward --- src/Http/Routing.Abstractions/src/PublicAPI.Unshipped.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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) From eca236699fcd406ef37796bee70d05c7df3eac58 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 22 Apr 2022 12:27:06 -0700 Subject: [PATCH 06/15] Apply suggestions from code review Co-authored-by: Kahbazi Co-authored-by: Brennan --- .../Routing/src/Builder/EndpointRouteBuilderExtensions.cs | 8 ++++---- src/Http/Routing/src/GroupRouteBuilder.cs | 2 +- src/Http/Routing/src/Patterns/RoutePatternFactory.cs | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs index ce22deb7392e..f0c85ec0843b 100644 --- a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs @@ -29,24 +29,24 @@ public static class EndpointRouteBuilderExtensions private static readonly string[] PatchVerb = new[] { HttpMethods.Patch }; /// - /// Create a for defining endpoints all prefixed with the specified . + /// 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 a and a . + /// 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 prefixPattern) => endpoints.MapGroup(RoutePatternFactory.Parse(prefixPattern ?? throw new ArgumentNullException(nameof(prefixPattern)))); /// - /// Create a for defining endpoints all prefixed with the specified . + /// 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 a and a . + /// 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 prefixPattern) diff --git a/src/Http/Routing/src/GroupRouteBuilder.cs b/src/Http/Routing/src/GroupRouteBuilder.cs index 003897e0bc1f..bae808c717da 100644 --- a/src/Http/Routing/src/GroupRouteBuilder.cs +++ b/src/Http/Routing/src/GroupRouteBuilder.cs @@ -71,7 +71,7 @@ public override IReadOnlyList Endpoints { foreach (var endpoint in dataSource.Endpoints) { - // Endpoint does not provide a RoutePattern but RouteEndpoint does. So it's impossible to apply a prefix custom 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) { diff --git a/src/Http/Routing/src/Patterns/RoutePatternFactory.cs b/src/Http/Routing/src/Patterns/RoutePatternFactory.cs index 9e32f8df4383..1d1e8b13da56 100644 --- a/src/Http/Routing/src/Patterns/RoutePatternFactory.cs +++ b/src/Http/Routing/src/Patterns/RoutePatternFactory.cs @@ -1122,8 +1122,8 @@ static IReadOnlyList CombineLists( // 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 `segements` param. `requiredValues` cannot be defined in - // `segements` so there's no equivalent to merging these until now. + // 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, From e4794450e7a9d0efae5c15ea15b3a6753de9b636 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 19 Apr 2022 18:27:49 -0700 Subject: [PATCH 07/15] Set RouteEndpointBuilder.ServiceProvider --- src/Http/Routing/src/GroupRouteBuilder.cs | 3 ++- ...ndlerEndpointRouteBuilderExtensionsTest.cs | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Http/Routing/src/GroupRouteBuilder.cs b/src/Http/Routing/src/GroupRouteBuilder.cs index bae808c717da..054cdc8a0018 100644 --- a/src/Http/Routing/src/GroupRouteBuilder.cs +++ b/src/Http/Routing/src/GroupRouteBuilder.cs @@ -86,9 +86,10 @@ public override IReadOnlyList Endpoints 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. + // 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); diff --git a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs index 945fdba0d939..e4e0ac1d0c28 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs @@ -1146,6 +1146,28 @@ public void MapGroup_RoutePatternInConvention_IncludesFullGroupPrefix() Assert.Equal("/outer/inner/foo", innerPattern?.RawText); } + [Fact] + public void MapGroup_ServiceProviderInConvention_IsSet() + { + var serviceProvider = new EmptyServiceProvider(); + 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 MapGroup_BuildingEndpointInConvention_Works() { From ae86a6a4cec2c62dcf3ac3d7478d6c2a9019fc76 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 22 Apr 2022 12:46:50 -0700 Subject: [PATCH 08/15] s/prefixPattern/prefix/ --- .../Builder/EndpointRouteBuilderExtensions.cs | 30 ++++++++----------- src/Http/Routing/src/PublicAPI.Unshipped.txt | 4 +-- ...ndlerEndpointRouteBuilderExtensionsTest.cs | 4 +-- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs index f0c85ec0843b..058304686084 100644 --- a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs @@ -29,38 +29,32 @@ public static class EndpointRouteBuilderExtensions private static readonly string[] PatchVerb = new[] { HttpMethods.Patch }; /// - /// Creates a for defining endpoints all prefixed with the specified . + /// 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. + /// 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. + /// 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 prefixPattern) => - endpoints.MapGroup(RoutePatternFactory.Parse(prefixPattern ?? throw new ArgumentNullException(nameof(prefixPattern)))); + 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 . + /// 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. + /// 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. + /// 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 prefixPattern) + public static GroupRouteBuilder MapGroup(this IEndpointRouteBuilder endpoints, RoutePattern prefix) { - if (endpoints is null) - { - throw new ArgumentNullException(nameof(endpoints)); - } - if (prefixPattern is null) - { - throw new ArgumentNullException(nameof(prefixPattern)); - } + ArgumentNullException.ThrowIfNull(endpoints, nameof(endpoints)); + ArgumentNullException.ThrowIfNull(prefix, nameof(prefix)); - return new(endpoints, prefixPattern); + return new(endpoints, prefix); } /// diff --git a/src/Http/Routing/src/PublicAPI.Unshipped.txt b/src/Http/Routing/src/PublicAPI.Unshipped.txt index f013257ec199..632d25fbd8af 100644 --- a/src/Http/Routing/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing/src/PublicAPI.Unshipped.txt @@ -4,8 +4,8 @@ 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! prefixPattern) -> Microsoft.AspNetCore.Routing.GroupRouteBuilder! -static Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapGroup(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! prefixPattern) -> Microsoft.AspNetCore.Routing.GroupRouteBuilder! +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/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs index e4e0ac1d0c28..1f21727bee51 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs @@ -1106,9 +1106,9 @@ public void MapGroup_WithNullParameters_ThrowsArgumentNullException() var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); var ex = Assert.Throws(() => builder.MapGroup((string)null!)); - Assert.Equal("prefixPattern", ex.ParamName); + Assert.Equal("prefix", ex.ParamName); ex = Assert.Throws(() => builder.MapGroup((RoutePattern)null!)); - Assert.Equal("prefixPattern", ex.ParamName); + Assert.Equal("prefix", ex.ParamName); builder = null; From c3da66882adf9355a9c73d77f5cf272ae157f7ed Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 22 Apr 2022 13:57:37 -0700 Subject: [PATCH 09/15] Add MapGroup_DataSourceFiresChangeToken_WhenInnerDataSourceFiresChangeToken --- ...ndlerEndpointRouteBuilderExtensionsTest.cs | 26 +++++++++++++++++++ .../CompositeEndpointDataSourceTest.cs | 17 +----------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs index 1f21727bee51..f459c7cffe50 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.AspNetCore.Routing.TestObjects; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -1260,6 +1261,31 @@ public void MapGroup_AddsOutermostGroupMetadataFirst() Assert.Equal("/foo", endpoint.Metadata[^1]); } + [Fact] + public void MapGroup_DataSourceFiresChangeToken_WhenInnerDataSourceFiresChangeToken() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); + 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); + } + class ServiceAccessingRouteHandlerFilter : IRouteHandlerFilter { private ILogger _logger; 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(); - } } From 437be358169522f4b47859c2bb1ee26f1f0e4ec5 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 22 Apr 2022 15:57:13 -0700 Subject: [PATCH 10/15] Add RoutingGroupsTest to Mvc.FunctionalTests --- .../Mvc.FunctionalTests/RoutingGroupsTest.cs | 52 +++++++++++++++++++ .../RoutingWebSite/StartupForGroups.cs | 41 +++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/Mvc/test/Mvc.FunctionalTests/RoutingGroupsTest.cs create mode 100644 src/Mvc/test/WebSites/RoutingWebSite/StartupForGroups.cs 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..44c0c4249360 --- /dev/null +++ b/src/Mvc/test/WebSites/RoutingWebSite/StartupForGroups.cs @@ -0,0 +1,41 @@ +// 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.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace RoutingWebSite; + +public class StartupForGroups +{ + // Set up application services + public void ConfigureServices(IServiceCollection services) + { + var pageRouteTransformerConvention = new PageRouteTransformerConvention(new SlugifyParameterTransformer()); + + 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(); + }); + } +} From 58ac411838d03e77e2b3ee06eb248d8f64d3378d Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 22 Apr 2022 16:10:48 -0700 Subject: [PATCH 11/15] Add MapGroup_ChangingMostEndpointBuilderPropertiesInConvention_Works --- ...ndlerEndpointRouteBuilderExtensionsTest.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs index f459c7cffe50..e10717be6566 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs @@ -1224,6 +1224,49 @@ public void MapGroup_ModifyingRoutePatternInConvention_ThrowsNotSupportedExcepti Assert.Equal("MapGroup does not support mutating RouteEndpointBuilder.RoutePattern from '/group/foo' to '/bar' via conventions.", ex.Message); } + [Fact] + public async Task MapGroup_ChangingMostEndpointBuilderPropertiesInConvention_Works() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); + + 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 MapGroup_GivenNonRouteEndpoint_ThrowsNotSupportedException() { From 42c8c0c02861800151032d98fd0894b58b765ffb Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 22 Apr 2022 16:17:30 -0700 Subject: [PATCH 12/15] Add MapGroup_SupportsMultipleEndpoints --- ...ndlerEndpointRouteBuilderExtensionsTest.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs index e10717be6566..c7e228c075ab 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs @@ -1304,6 +1304,33 @@ public void MapGroup_AddsOutermostGroupMetadataFirst() Assert.Equal("/foo", endpoint.Metadata[^1]); } + [Fact] + public void MapGroup_SupportsMultipleEndpoints() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); + + 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 MapGroup_DataSourceFiresChangeToken_WhenInnerDataSourceFiresChangeToken() { From 031e5b6844a2ff8219474fdfe96852adb4040e79 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 22 Apr 2022 16:25:13 -0700 Subject: [PATCH 13/15] Move new tests to GroupTest.cs --- .../test/UnitTests/Builder/GroupTest.cs | 407 ++++++++++++++++++ ...ndlerEndpointRouteBuilderExtensionsTest.cs | 395 ----------------- 2 files changed, 407 insertions(+), 395 deletions(-) create mode 100644 src/Http/Routing/test/UnitTests/Builder/GroupTest.cs 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..a65b5083f121 --- /dev/null +++ b/src/Http/Routing/test/UnitTests/Builder/GroupTest.cs @@ -0,0 +1,407 @@ +// 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.AspNetCore.Routing.TestObjects; +using Microsoft.Extensions.Primitives; +using Moq; + +namespace Microsoft.AspNetCore.Routing.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 c7e228c075ab..09e0e7e87392 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs @@ -6,8 +6,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.Routing.Patterns; -using Microsoft.AspNetCore.Routing.TestObjects; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -23,11 +21,6 @@ private ModelEndpointDataSource GetBuilderEndpointDataSource(IEndpointRouteBuild return Assert.IsType(Assert.Single(endpointRouteBuilder.DataSources)); } - private EndpointDataSource GetEndpointDataSource(IEndpointRouteBuilder endpointRouteBuilder) - { - return Assert.IsAssignableFrom(Assert.Single(endpointRouteBuilder.DataSources)); - } - private RouteEndpointBuilder GetRouteEndpointBuilder(IEndpointRouteBuilder endpointRouteBuilder) { return Assert.IsType(Assert.Single(GetBuilderEndpointDataSource(endpointRouteBuilder).EndpointBuilders)); @@ -979,383 +972,6 @@ public async Task RequestDelegateFactory_CanInvokeEndpointFilter_ThatAccessesSer Assert.Equal("loggerErrorIsEnabled: True, parentName: RouteHandlerEndpointRouteBuilderExtensionsTest", body); } - [Fact] - public async Task MapGroup_Prefix_CanBeEmpty() - { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); - - 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 MapGroup_PrefixWithRouteParameter_CanBeUsed() - { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); - - 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 MapGroup_NestedPrefixWithRouteParameters_CanBeUsed() - { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); - - 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 MapGroup_WithRepeatedRouteParameter_ThrowsRoutePatternException() - { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); - - 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 MapGroup_WithNullParameters_ThrowsArgumentNullException() - { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); - - 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 MapGroup_RoutePatternInConvention_IncludesFullGroupPrefix() - { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); - - 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 MapGroup_ServiceProviderInConvention_IsSet() - { - var serviceProvider = new EmptyServiceProvider(); - 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 MapGroup_BuildingEndpointInConvention_Works() - { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); - - 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 MapGroup_ModifyingRoutePatternInConvention_ThrowsNotSupportedException() - { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); - - 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 MapGroup_ChangingMostEndpointBuilderPropertiesInConvention_Works() - { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); - - 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 MapGroup_GivenNonRouteEndpoint_ThrowsNotSupportedException() - { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); - - 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.RouteHandlerEndpointRouteBuilderExtensionsTest+TestCustomEndpoint'. " + - "Only RouteEndpoints can be grouped.", - ex.Message); - } - - [Fact] - public void MapGroup_AddsOutermostGroupMetadataFirst() - { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); - - 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 MapGroup_SupportsMultipleEndpoints() - { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); - - 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 MapGroup_DataSourceFiresChangeToken_WhenInnerDataSourceFiresChangeToken() - { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvider())); - 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); - } - class ServiceAccessingRouteHandlerFilter : IRouteHandlerFilter { private ILogger _logger; @@ -1475,15 +1091,4 @@ public void Dispose() return null; } } - - 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(); - } } From fbfd675f032bdf4a581aba389c15cf2c8ec33a81 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 22 Apr 2022 16:27:39 -0700 Subject: [PATCH 14/15] Clean StartupForGroups --- src/Mvc/test/WebSites/RoutingWebSite/StartupForGroups.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Mvc/test/WebSites/RoutingWebSite/StartupForGroups.cs b/src/Mvc/test/WebSites/RoutingWebSite/StartupForGroups.cs index 44c0c4249360..24eb14cb90cc 100644 --- a/src/Mvc/test/WebSites/RoutingWebSite/StartupForGroups.cs +++ b/src/Mvc/test/WebSites/RoutingWebSite/StartupForGroups.cs @@ -2,9 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Mvc.Routing; namespace RoutingWebSite; @@ -13,11 +11,7 @@ public class StartupForGroups // Set up application services public void ConfigureServices(IServiceCollection services) { - var pageRouteTransformerConvention = new PageRouteTransformerConvention(new SlugifyParameterTransformer()); - - services - .AddMvc() - .AddNewtonsoftJson(); + services.AddMvc().AddNewtonsoftJson(); // Used by some controllers defined in this project. services.Configure(options => options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer)); From 7d21d33d524d29e06cb30e165f68733dbbbd4955 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Mon, 25 Apr 2022 09:56:16 -0700 Subject: [PATCH 15/15] Fix nullable reference warning in GroupTests.cs --- .../test/UnitTests/Builder/GroupTest.cs | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/Http/Routing/test/UnitTests/Builder/GroupTest.cs b/src/Http/Routing/test/UnitTests/Builder/GroupTest.cs index a65b5083f121..1d77e8d47896 100644 --- a/src/Http/Routing/test/UnitTests/Builder/GroupTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/GroupTest.cs @@ -1,14 +1,16 @@ // 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; +#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.Routing.Builder; +namespace Microsoft.AspNetCore.Builder; public class GroupTest { @@ -20,7 +22,7 @@ private EndpointDataSource GetEndpointDataSource(IEndpointRouteBuilder endpointR [Fact] public async Task Prefix_CanBeEmpty() { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null)); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); var group = builder.MapGroup(""); Assert.Equal("", group.GroupPrefix.RawText); @@ -55,7 +57,7 @@ public async Task Prefix_CanBeEmpty() [Fact] public async Task PrefixWithRouteParameter_CanBeUsed() { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null)); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); var group = builder.MapGroup("/{org}"); Assert.Equal("/{org}", group.GroupPrefix.RawText); @@ -93,7 +95,7 @@ public async Task PrefixWithRouteParameter_CanBeUsed() [Fact] public async Task NestedPrefixWithRouteParameters_CanBeUsed() { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null)); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); var group = builder.MapGroup("/{org}").MapGroup("/{id}"); Assert.Equal("/{org}/{id}", group.GroupPrefix.RawText); @@ -131,7 +133,7 @@ public async Task NestedPrefixWithRouteParameters_CanBeUsed() [Fact] public void RepeatedRouteParameter_ThrowsRoutePatternException() { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null)); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); var ex = Assert.Throws(() => builder.MapGroup("/{ID}").MapGroup("/{id}")); @@ -142,7 +144,7 @@ public void RepeatedRouteParameter_ThrowsRoutePatternException() [Fact] public void NullParameters_ThrowsArgumentNullException() { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null)); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); var ex = Assert.Throws(() => builder.MapGroup((string)null!)); Assert.Equal("prefix", ex.ParamName); @@ -160,7 +162,7 @@ public void NullParameters_ThrowsArgumentNullException() [Fact] public void RoutePatternInConvention_IncludesFullGroupPrefix() { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null)); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); var outer = builder.MapGroup("/outer"); var inner = outer.MapGroup("/inner"); @@ -210,7 +212,7 @@ public void ServiceProviderInConvention_IsSet() [Fact] public async Task BuildingEndpointInConvention_Works() { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null)); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); var group = builder.MapGroup("/group"); var mapGetCalled = false; @@ -247,7 +249,7 @@ public async Task BuildingEndpointInConvention_Works() [Fact] public void ModifyingRoutePatternInConvention_ThrowsNotSupportedException() { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null)); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); var group = builder.MapGroup("/group"); group.MapGet("/foo", () => "Hello World!"); @@ -265,7 +267,7 @@ public void ModifyingRoutePatternInConvention_ThrowsNotSupportedException() [Fact] public async Task ChangingMostEndpointBuilderPropertiesInConvention_Works() { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null)); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); var group = builder.MapGroup("/group"); var mapGetCalled = false; @@ -308,7 +310,7 @@ public async Task ChangingMostEndpointBuilderPropertiesInConvention_Works() [Fact] public void GivenNonRouteEndpoint_ThrowsNotSupportedException() { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null)); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); var group = builder.MapGroup("/group"); ((IEndpointRouteBuilder)group).DataSources.Add(new TestCustomEndpintDataSource()); @@ -324,7 +326,7 @@ public void GivenNonRouteEndpoint_ThrowsNotSupportedException() [Fact] public void OuterGroupMetadata_AddedFirst() { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null)); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); var outer = builder.MapGroup("/outer"); var inner = outer.MapGroup("/inner"); @@ -345,7 +347,7 @@ public void OuterGroupMetadata_AddedFirst() [Fact] public void MultipleEndpoints_AreSupported() { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null)); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); var group = builder.MapGroup("/group"); group.MapGet("/foo", () => "foo"); @@ -372,7 +374,7 @@ public void MultipleEndpoints_AreSupported() [Fact] public void DataSourceFiresChangeToken_WhenInnerDataSourceFiresChangeToken() { - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null)); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider: null!)); var dynamicDataSource = new DynamicEndpointDataSource(); var group = builder.MapGroup("/group");