Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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)
53 changes: 47 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,41 @@ public static class EndpointRouteBuilderExtensions
private static readonly string[] DeleteVerb = new[] { HttpMethods.Delete };
private static readonly string[] PatchVerb = new[] { HttpMethods.Patch };

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

/// <summary>
/// Create a <see cref="GroupRouteBuilder"/> for defining endpoints all prefixed with the specified <paramref name="prefixPattern"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the group to.</param>
/// <param name="prefixPattern">The pattern that prefixes all routes in this group.</param>
/// <returns>
/// A <see cref="GroupRouteBuilder"/> that is both a <see cref="IEndpointRouteBuilder"/> and a <see cref="IEndpointConventionBuilder"/>.
/// The same builder can be used to add endpoints with the given <paramref name="prefixPattern"/>, and to customize those endpoints using conventions.
/// </returns>
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);
}

/// <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 +529,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 +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<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
139 changes: 139 additions & 0 deletions src/Http/Routing/src/GroupRouteBuilder.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <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 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();
}
}
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 `segements` param. `requiredValues` cannot be defined in
// `segements` 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