Skip to content

[release/7.0-preview4] Add MapGroup #41367

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#nullable enable
*REMOVED*abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string!
*REMOVED*Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object?
Microsoft.AspNetCore.Builder.EndpointBuilder.ServiceProvider.get -> System.IServiceProvider?
Microsoft.AspNetCore.Builder.EndpointBuilder.ServiceProvider.set -> void
Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object!
Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata<T>() -> T!
Microsoft.AspNetCore.Http.IRouteHandlerFilter.InvokeAsync(Microsoft.AspNetCore.Http.RouteHandlerInvocationContext! context, Microsoft.AspNetCore.Http.RouteHandlerFilterDelegate! next) -> System.Threading.Tasks.ValueTask<object?>
Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,25 +162,26 @@ public T GetRequiredMetadata<T>() where T : class
/// <summary>
/// Enumerates the elements of an <see cref="EndpointMetadataCollection"/>.
/// </summary>
public struct Enumerator : IEnumerator<object?>
public struct Enumerator : IEnumerator<object>
{
#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;
}

/// <summary>
/// Gets the element at the current position of the enumerator
/// </summary>
public object? Current { get; private set; }
public object Current => _current!;

/// <summary>
/// Releases all resources used by the <see cref="Enumerator"/>.
Expand All @@ -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;
}

Expand All @@ -214,7 +215,7 @@ public bool MoveNext()
public void Reset()
{
_index = 0;
Current = null;
_current = null;
}
}
}
4 changes: 3 additions & 1 deletion src/Http/Routing.Abstractions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
#nullable enable
Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata<T>() -> T! (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions)
*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>() -> T! (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions)
47 changes: 41 additions & 6 deletions src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,35 @@ public static class EndpointRouteBuilderExtensions
private static readonly string[] DeleteVerb = new[] { HttpMethods.Delete };
private static readonly string[] PatchVerb = new[] { HttpMethods.Patch };

/// <summary>
/// Creates a <see cref="GroupRouteBuilder"/> for defining endpoints all prefixed with the specified <paramref name="prefix"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the group to.</param>
/// <param name="prefix">The pattern that prefixes all routes in this group.</param>
/// <returns>
/// A <see cref="GroupRouteBuilder"/> that is both an <see cref="IEndpointRouteBuilder"/> and an <see cref="IEndpointConventionBuilder"/>.
/// The same builder can be used to add endpoints with the given <paramref name="prefix"/>, and to customize those endpoints using conventions.
/// </returns>
public static GroupRouteBuilder MapGroup(this IEndpointRouteBuilder endpoints, string prefix) =>
endpoints.MapGroup(RoutePatternFactory.Parse(prefix ?? throw new ArgumentNullException(nameof(prefix))));

/// <summary>
/// Creates a <see cref="GroupRouteBuilder"/> for defining endpoints all prefixed with the specified <paramref name="prefix"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the group to.</param>
/// <param name="prefix">The pattern that prefixes all routes in this group.</param>
/// <returns>
/// A <see cref="GroupRouteBuilder"/> that is both an <see cref="IEndpointRouteBuilder"/> and an <see cref="IEndpointConventionBuilder"/>.
/// The same builder can be used to add endpoints with the given <paramref name="prefix"/>, and to customize those endpoints using conventions.
/// </returns>
public static GroupRouteBuilder MapGroup(this IEndpointRouteBuilder endpoints, RoutePattern prefix)
{
ArgumentNullException.ThrowIfNull(endpoints, nameof(endpoints));
ArgumentNullException.ThrowIfNull(prefix, nameof(prefix));

return new(endpoints, prefix);
}

/// <summary>
/// Adds a <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that matches HTTP GET requests
/// for the specified pattern.
Expand Down Expand Up @@ -494,19 +523,18 @@ private static RouteHandlerBuilder Map(

const int defaultOrder = 0;

var routeParams = new List<string>(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<IOptions<RouteHandlerOptions>>();

var builder = new RouteEndpointBuilder(
pattern,
defaultOrder)
{
DisplayName = pattern.RawText ?? pattern.DebuggerToString(),
DisplayName = fullPattern.RawText ?? fullPattern.DebuggerToString(),
ServiceProvider = endpoints.ServiceProvider,
};

Expand Down Expand Up @@ -534,6 +562,13 @@ private static RouteHandlerBuilder Map(
"The trimmer is unable to infer this on the nested lambda.")]
void RouteHandlerBuilderConvention(EndpointBuilder endpointBuilder)
{
var routeParams = new List<string>(fullPattern.Parameters.Count);
foreach (var part in fullPattern.Parameters)
{
routeParams.Add(part.Name);
}

var routeHandlerOptions = endpoints.ServiceProvider?.GetService<IOptions<RouteHandlerOptions>>();
var options = new RequestDelegateFactoryOptions
{
ServiceProvider = endpoints.ServiceProvider,
Expand Down
140 changes: 140 additions & 0 deletions src/Http/Routing/src/GroupRouteBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.Primitives;

namespace Microsoft.AspNetCore.Routing;

/// <summary>
/// A builder for defining groups of endpoints with a common prefix that implements both the <see cref="IEndpointRouteBuilder"/>
/// and <see cref="IEndpointConventionBuilder"/> interfaces. This can be used to add endpoints with the given <see cref="GroupPrefix"/>,
/// and to customize those endpoints using conventions.
/// </summary>
public sealed class GroupRouteBuilder : IEndpointRouteBuilder, IEndpointConventionBuilder
{
private readonly IEndpointRouteBuilder _outerEndpointRouteBuilder;
private readonly RoutePattern _pattern;

private readonly List<EndpointDataSource> _dataSources = new();
private readonly List<Action<EndpointBuilder>> _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));
}

/// <summary>
/// The <see cref="RoutePattern"/> prefixing all endpoints defined using this <see cref="GroupRouteBuilder"/>.
/// This accounts for nested groups and gives the full group prefix, not just the prefix supplied to the last call to
/// <see cref="EndpointRouteBuilderExtensions.MapGroup(IEndpointRouteBuilder, RoutePattern)"/>.
/// </summary>
public RoutePattern GroupPrefix { get; }

IServiceProvider IEndpointRouteBuilder.ServiceProvider => _outerEndpointRouteBuilder.ServiceProvider;
IApplicationBuilder IEndpointRouteBuilder.CreateApplicationBuilder() => _outerEndpointRouteBuilder.CreateApplicationBuilder();
ICollection<EndpointDataSource> IEndpointRouteBuilder.DataSources => _dataSources;
void IEndpointConventionBuilder.Add(Action<EndpointBuilder> 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<Endpoint> Endpoints
{
get
{
var list = new List<Endpoint>();

foreach (var dataSource in _groupRouteBuilder._dataSources)
{
foreach (var endpoint in dataSource.Endpoints)
{
// Endpoint does not provide a RoutePattern but RouteEndpoint does. So it's impossible to apply a prefix for custom Endpoints.
// Supporting arbitrary Endpoints just to add group metadata would require changing the Endpoint type breaking any real scenario.
if (endpoint is not RouteEndpoint routeEndpoint)
{
throw new NotSupportedException(Resources.FormatMapGroup_CustomEndpointUnsupported(endpoint.GetType()));
}

// Make the full route pattern visible to IEndpointConventionBuilder extension methods called on the group.
// This includes patterns from any parent groups.
var fullRoutePattern = RoutePatternFactory.Combine(_groupRouteBuilder.GroupPrefix, routeEndpoint.RoutePattern);

// RequestDelegate can never be null on a RouteEndpoint. The nullability carries over from Endpoint.
var routeEndpointBuilder = new RouteEndpointBuilder(routeEndpoint.RequestDelegate!, fullRoutePattern, routeEndpoint.Order)
{
DisplayName = routeEndpoint.DisplayName,
ServiceProvider = _groupRouteBuilder._outerEndpointRouteBuilder.ServiceProvider,
};

// Apply group conventions to each endpoint in the group at a lower precedent than metadata already on the endpoint.
foreach (var convention in _groupRouteBuilder._conventions)
{
convention(routeEndpointBuilder);
}

// If we supported mutating the route pattern via a group convention, RouteEndpointBuilder.RoutePattern would have
// to be the partialRoutePattern (below) instead of the fullRoutePattern (above) since that's all we can control. We cannot
// change a parent prefix. In order to allow to conventions to read the fullRoutePattern, we do not support mutation.
if (!ReferenceEquals(fullRoutePattern, routeEndpointBuilder.RoutePattern))
{
throw new NotSupportedException(Resources.FormatMapGroup_ChangingRoutePatternUnsupported(
fullRoutePattern.RawText, routeEndpointBuilder.RoutePattern.RawText));
}

// Any metadata already on the RouteEndpoint must have been applied directly to the endpoint or to a nested group.
// This makes the metadata more specific than what's being applied to this group. So add it after this group's conventions.
//
// REVIEW: This means group conventions don't get visibility into endpoint-specific metadata nor the ability to override it.
// We should consider allowing group-aware conventions the ability to read and mutate this metadata in future releases.
foreach (var metadata in routeEndpoint.Metadata)
{
routeEndpointBuilder.Metadata.Add(metadata);
}

// Use _pattern instead of GroupPrefix when we're calculating an intermediate RouteEndpoint.
var partialRoutePattern = _groupRouteBuilder.IsRoot
? fullRoutePattern : RoutePatternFactory.Combine(_groupRouteBuilder._pattern, routeEndpoint.RoutePattern);

// The RequestDelegate, Order and DisplayName can all be overridden by non-group-aware conventions. Unlike with metadata,
// if a convention is applied to a group that changes any of these, I would expect these to be overridden as there's no
// reasonable way to merge these properties.
list.Add(new RouteEndpoint(
// Again, RequestDelegate can never be null given a RouteEndpoint.
routeEndpointBuilder.RequestDelegate!,
partialRoutePattern,
routeEndpointBuilder.Order,
new(routeEndpointBuilder.Metadata),
routeEndpointBuilder.DisplayName));
}
}

return list;
}
}

public override IChangeToken GetChangeToken() => new CompositeEndpointDataSource(_groupRouteBuilder._dataSources).GetChangeToken();
}
}
102 changes: 102 additions & 0 deletions src/Http/Routing/src/Patterns/RoutePatternFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,108 @@ public static RoutePatternParameterPolicyReference ParameterPolicy(string parame
return ParameterPolicyCore(parameterPolicy);
}

internal static RoutePattern Combine(RoutePattern left, RoutePattern right)
{
static IReadOnlyList<T> CombineLists<T>(
IReadOnlyList<T> leftList,
IReadOnlyList<T> rightList,
Func<int, string, Action<T>>? 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<T>(combinedCount);
// If checkDuplicates is set, so is rawText so the right exception can be thrown from check.
var check = checkDuplicates?.Invoke(combinedCount, rawText!);
foreach (var item in leftList)
{
check?.Invoke(item);
combinedList.Add(item);
}
foreach (var item in rightList)
{
check?.Invoke(item);
combinedList.Add(item);
}
return combinedList;
}

// Technically, the ParameterPolicies could probably be merged because it's a list, but it makes little sense to add policy
// for the same parameter in both the left and right part of the combined pattern. Defaults and Required values cannot be
// merged because the `TValue` is `object?`, but over-setting a Default or RequiredValue (which may not be in the parameter list)
// seems okay as long as the values are the same for a given key in both the left and right pattern. There's already similar logic
// in PatternCore for when defaults come from both the `defaults` and `segments` param. `requiredValues` cannot be defined in
// `segments` so there's no equivalent to merging these until now.
static IReadOnlyDictionary<string, TValue> CombineDictionaries<TValue>(
IReadOnlyDictionary<string, TValue> leftDictionary,
IReadOnlyDictionary<string, TValue> rightDictionary,
string rawText,
string dictionaryName)
{
if (leftDictionary.Count is 0)
{
return rightDictionary;
}
if (rightDictionary.Count is 0)
{
return leftDictionary;
}

var combinedDictionary = new Dictionary<string, TValue>(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<RoutePatternParameterPart> CheckDuplicateParameters(int parameterCount, string rawText)
{
var parameterNameSet = new HashSet<string>(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);
Expand Down
Loading