Skip to content

Commit bf03cee

Browse files
authored
Add MapGroup (#41265)
1 parent b2498d0 commit bf03cee

File tree

17 files changed

+963
-36
lines changed

17 files changed

+963
-36
lines changed

src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
#nullable enable
22
*REMOVED*abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string!
3+
*REMOVED*Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object?
34
Microsoft.AspNetCore.Builder.EndpointBuilder.ServiceProvider.get -> System.IServiceProvider?
45
Microsoft.AspNetCore.Builder.EndpointBuilder.ServiceProvider.set -> void
6+
Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object!
57
Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata<T>() -> T!
68
Microsoft.AspNetCore.Http.IRouteHandlerFilter.InvokeAsync(Microsoft.AspNetCore.Http.RouteHandlerInvocationContext! context, Microsoft.AspNetCore.Http.RouteHandlerFilterDelegate! next) -> System.Threading.Tasks.ValueTask<object?>
79
Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata

src/Http/Http.Abstractions/src/Routing/EndpointMetadataCollection.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,25 +162,26 @@ public T GetRequiredMetadata<T>() where T : class
162162
/// <summary>
163163
/// Enumerates the elements of an <see cref="EndpointMetadataCollection"/>.
164164
/// </summary>
165-
public struct Enumerator : IEnumerator<object?>
165+
public struct Enumerator : IEnumerator<object>
166166
{
167167
#pragma warning disable IDE0044
168168
// Intentionally not readonly to prevent defensive struct copies
169169
private object[] _items;
170170
#pragma warning restore IDE0044
171171
private int _index;
172+
private object? _current;
172173

173174
internal Enumerator(EndpointMetadataCollection collection)
174175
{
175176
_items = collection._items;
176177
_index = 0;
177-
Current = null;
178+
_current = null;
178179
}
179180

180181
/// <summary>
181182
/// Gets the element at the current position of the enumerator
182183
/// </summary>
183-
public object? Current { get; private set; }
184+
public object Current => _current!;
184185

185186
/// <summary>
186187
/// Releases all resources used by the <see cref="Enumerator"/>.
@@ -200,11 +201,11 @@ public bool MoveNext()
200201
{
201202
if (_index < _items.Length)
202203
{
203-
Current = _items[_index++];
204+
_current = _items[_index++];
204205
return true;
205206
}
206207

207-
Current = null;
208+
_current = null;
208209
return false;
209210
}
210211

@@ -214,7 +215,7 @@ public bool MoveNext()
214215
public void Reset()
215216
{
216217
_index = 0;
217-
Current = null;
218+
_current = null;
218219
}
219220
}
220221
}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
#nullable enable
2-
Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata<T>() -> T! (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions)
2+
*REMOVED*Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object? (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions)
3+
Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object! (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions)
4+
Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata<T>() -> T! (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions)

src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,35 @@ public static class EndpointRouteBuilderExtensions
2828
private static readonly string[] DeleteVerb = new[] { HttpMethods.Delete };
2929
private static readonly string[] PatchVerb = new[] { HttpMethods.Patch };
3030

31+
/// <summary>
32+
/// Creates a <see cref="GroupRouteBuilder"/> for defining endpoints all prefixed with the specified <paramref name="prefix"/>.
33+
/// </summary>
34+
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the group to.</param>
35+
/// <param name="prefix">The pattern that prefixes all routes in this group.</param>
36+
/// <returns>
37+
/// A <see cref="GroupRouteBuilder"/> that is both an <see cref="IEndpointRouteBuilder"/> and an <see cref="IEndpointConventionBuilder"/>.
38+
/// The same builder can be used to add endpoints with the given <paramref name="prefix"/>, and to customize those endpoints using conventions.
39+
/// </returns>
40+
public static GroupRouteBuilder MapGroup(this IEndpointRouteBuilder endpoints, string prefix) =>
41+
endpoints.MapGroup(RoutePatternFactory.Parse(prefix ?? throw new ArgumentNullException(nameof(prefix))));
42+
43+
/// <summary>
44+
/// Creates a <see cref="GroupRouteBuilder"/> for defining endpoints all prefixed with the specified <paramref name="prefix"/>.
45+
/// </summary>
46+
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the group to.</param>
47+
/// <param name="prefix">The pattern that prefixes all routes in this group.</param>
48+
/// <returns>
49+
/// A <see cref="GroupRouteBuilder"/> that is both an <see cref="IEndpointRouteBuilder"/> and an <see cref="IEndpointConventionBuilder"/>.
50+
/// The same builder can be used to add endpoints with the given <paramref name="prefix"/>, and to customize those endpoints using conventions.
51+
/// </returns>
52+
public static GroupRouteBuilder MapGroup(this IEndpointRouteBuilder endpoints, RoutePattern prefix)
53+
{
54+
ArgumentNullException.ThrowIfNull(endpoints, nameof(endpoints));
55+
ArgumentNullException.ThrowIfNull(prefix, nameof(prefix));
56+
57+
return new(endpoints, prefix);
58+
}
59+
3160
/// <summary>
3261
/// Adds a <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that matches HTTP GET requests
3362
/// for the specified pattern.
@@ -494,19 +523,18 @@ private static RouteHandlerBuilder Map(
494523

495524
const int defaultOrder = 0;
496525

497-
var routeParams = new List<string>(pattern.Parameters.Count);
498-
foreach (var part in pattern.Parameters)
526+
var fullPattern = pattern;
527+
528+
if (endpoints is GroupRouteBuilder group)
499529
{
500-
routeParams.Add(part.Name);
530+
fullPattern = RoutePatternFactory.Combine(group.GroupPrefix, pattern);
501531
}
502532

503-
var routeHandlerOptions = endpoints.ServiceProvider?.GetService<IOptions<RouteHandlerOptions>>();
504-
505533
var builder = new RouteEndpointBuilder(
506534
pattern,
507535
defaultOrder)
508536
{
509-
DisplayName = pattern.RawText ?? pattern.DebuggerToString(),
537+
DisplayName = fullPattern.RawText ?? fullPattern.DebuggerToString(),
510538
ServiceProvider = endpoints.ServiceProvider,
511539
};
512540

@@ -534,6 +562,13 @@ private static RouteHandlerBuilder Map(
534562
"The trimmer is unable to infer this on the nested lambda.")]
535563
void RouteHandlerBuilderConvention(EndpointBuilder endpointBuilder)
536564
{
565+
var routeParams = new List<string>(fullPattern.Parameters.Count);
566+
foreach (var part in fullPattern.Parameters)
567+
{
568+
routeParams.Add(part.Name);
569+
}
570+
571+
var routeHandlerOptions = endpoints.ServiceProvider?.GetService<IOptions<RouteHandlerOptions>>();
537572
var options = new RequestDelegateFactoryOptions
538573
{
539574
ServiceProvider = endpoints.ServiceProvider,
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Builder;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.AspNetCore.Routing.Patterns;
7+
using Microsoft.Extensions.Primitives;
8+
9+
namespace Microsoft.AspNetCore.Routing;
10+
11+
/// <summary>
12+
/// A builder for defining groups of endpoints with a common prefix that implements both the <see cref="IEndpointRouteBuilder"/>
13+
/// and <see cref="IEndpointConventionBuilder"/> interfaces. This can be used to add endpoints with the given <see cref="GroupPrefix"/>,
14+
/// and to customize those endpoints using conventions.
15+
/// </summary>
16+
public sealed class GroupRouteBuilder : IEndpointRouteBuilder, IEndpointConventionBuilder
17+
{
18+
private readonly IEndpointRouteBuilder _outerEndpointRouteBuilder;
19+
private readonly RoutePattern _pattern;
20+
21+
private readonly List<EndpointDataSource> _dataSources = new();
22+
private readonly List<Action<EndpointBuilder>> _conventions = new();
23+
24+
internal GroupRouteBuilder(IEndpointRouteBuilder outerEndpointRouteBuilder, RoutePattern pattern)
25+
{
26+
_outerEndpointRouteBuilder = outerEndpointRouteBuilder;
27+
_pattern = pattern;
28+
29+
if (outerEndpointRouteBuilder is GroupRouteBuilder outerGroup)
30+
{
31+
GroupPrefix = RoutePatternFactory.Combine(outerGroup.GroupPrefix, pattern);
32+
}
33+
else
34+
{
35+
GroupPrefix = pattern;
36+
}
37+
38+
_outerEndpointRouteBuilder.DataSources.Add(new GroupDataSource(this));
39+
}
40+
41+
/// <summary>
42+
/// The <see cref="RoutePattern"/> prefixing all endpoints defined using this <see cref="GroupRouteBuilder"/>.
43+
/// This accounts for nested groups and gives the full group prefix, not just the prefix supplied to the last call to
44+
/// <see cref="EndpointRouteBuilderExtensions.MapGroup(IEndpointRouteBuilder, RoutePattern)"/>.
45+
/// </summary>
46+
public RoutePattern GroupPrefix { get; }
47+
48+
IServiceProvider IEndpointRouteBuilder.ServiceProvider => _outerEndpointRouteBuilder.ServiceProvider;
49+
IApplicationBuilder IEndpointRouteBuilder.CreateApplicationBuilder() => _outerEndpointRouteBuilder.CreateApplicationBuilder();
50+
ICollection<EndpointDataSource> IEndpointRouteBuilder.DataSources => _dataSources;
51+
void IEndpointConventionBuilder.Add(Action<EndpointBuilder> convention) => _conventions.Add(convention);
52+
53+
private bool IsRoot => ReferenceEquals(GroupPrefix, _pattern);
54+
55+
private sealed class GroupDataSource : EndpointDataSource
56+
{
57+
private readonly GroupRouteBuilder _groupRouteBuilder;
58+
59+
public GroupDataSource(GroupRouteBuilder groupRouteBuilder)
60+
{
61+
_groupRouteBuilder = groupRouteBuilder;
62+
}
63+
64+
public override IReadOnlyList<Endpoint> Endpoints
65+
{
66+
get
67+
{
68+
var list = new List<Endpoint>();
69+
70+
foreach (var dataSource in _groupRouteBuilder._dataSources)
71+
{
72+
foreach (var endpoint in dataSource.Endpoints)
73+
{
74+
// Endpoint does not provide a RoutePattern but RouteEndpoint does. So it's impossible to apply a prefix for custom Endpoints.
75+
// Supporting arbitrary Endpoints just to add group metadata would require changing the Endpoint type breaking any real scenario.
76+
if (endpoint is not RouteEndpoint routeEndpoint)
77+
{
78+
throw new NotSupportedException(Resources.FormatMapGroup_CustomEndpointUnsupported(endpoint.GetType()));
79+
}
80+
81+
// Make the full route pattern visible to IEndpointConventionBuilder extension methods called on the group.
82+
// This includes patterns from any parent groups.
83+
var fullRoutePattern = RoutePatternFactory.Combine(_groupRouteBuilder.GroupPrefix, routeEndpoint.RoutePattern);
84+
85+
// RequestDelegate can never be null on a RouteEndpoint. The nullability carries over from Endpoint.
86+
var routeEndpointBuilder = new RouteEndpointBuilder(routeEndpoint.RequestDelegate!, fullRoutePattern, routeEndpoint.Order)
87+
{
88+
DisplayName = routeEndpoint.DisplayName,
89+
ServiceProvider = _groupRouteBuilder._outerEndpointRouteBuilder.ServiceProvider,
90+
};
91+
92+
// Apply group conventions to each endpoint in the group at a lower precedent than metadata already on the endpoint.
93+
foreach (var convention in _groupRouteBuilder._conventions)
94+
{
95+
convention(routeEndpointBuilder);
96+
}
97+
98+
// If we supported mutating the route pattern via a group convention, RouteEndpointBuilder.RoutePattern would have
99+
// to be the partialRoutePattern (below) instead of the fullRoutePattern (above) since that's all we can control. We cannot
100+
// change a parent prefix. In order to allow to conventions to read the fullRoutePattern, we do not support mutation.
101+
if (!ReferenceEquals(fullRoutePattern, routeEndpointBuilder.RoutePattern))
102+
{
103+
throw new NotSupportedException(Resources.FormatMapGroup_ChangingRoutePatternUnsupported(
104+
fullRoutePattern.RawText, routeEndpointBuilder.RoutePattern.RawText));
105+
}
106+
107+
// Any metadata already on the RouteEndpoint must have been applied directly to the endpoint or to a nested group.
108+
// This makes the metadata more specific than what's being applied to this group. So add it after this group's conventions.
109+
//
110+
// REVIEW: This means group conventions don't get visibility into endpoint-specific metadata nor the ability to override it.
111+
// We should consider allowing group-aware conventions the ability to read and mutate this metadata in future releases.
112+
foreach (var metadata in routeEndpoint.Metadata)
113+
{
114+
routeEndpointBuilder.Metadata.Add(metadata);
115+
}
116+
117+
// Use _pattern instead of GroupPrefix when we're calculating an intermediate RouteEndpoint.
118+
var partialRoutePattern = _groupRouteBuilder.IsRoot
119+
? fullRoutePattern : RoutePatternFactory.Combine(_groupRouteBuilder._pattern, routeEndpoint.RoutePattern);
120+
121+
// The RequestDelegate, Order and DisplayName can all be overridden by non-group-aware conventions. Unlike with metadata,
122+
// if a convention is applied to a group that changes any of these, I would expect these to be overridden as there's no
123+
// reasonable way to merge these properties.
124+
list.Add(new RouteEndpoint(
125+
// Again, RequestDelegate can never be null given a RouteEndpoint.
126+
routeEndpointBuilder.RequestDelegate!,
127+
partialRoutePattern,
128+
routeEndpointBuilder.Order,
129+
new(routeEndpointBuilder.Metadata),
130+
routeEndpointBuilder.DisplayName));
131+
}
132+
}
133+
134+
return list;
135+
}
136+
}
137+
138+
public override IChangeToken GetChangeToken() => new CompositeEndpointDataSource(_groupRouteBuilder._dataSources).GetChangeToken();
139+
}
140+
}

src/Http/Routing/src/Patterns/RoutePatternFactory.cs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,6 +1084,108 @@ public static RoutePatternParameterPolicyReference ParameterPolicy(string parame
10841084
return ParameterPolicyCore(parameterPolicy);
10851085
}
10861086

1087+
internal static RoutePattern Combine(RoutePattern left, RoutePattern right)
1088+
{
1089+
static IReadOnlyList<T> CombineLists<T>(
1090+
IReadOnlyList<T> leftList,
1091+
IReadOnlyList<T> rightList,
1092+
Func<int, string, Action<T>>? checkDuplicates = null,
1093+
string? rawText = null)
1094+
{
1095+
if (leftList.Count is 0)
1096+
{
1097+
return rightList;
1098+
}
1099+
if (rightList.Count is 0)
1100+
{
1101+
return leftList;
1102+
}
1103+
1104+
var combinedCount = leftList.Count + rightList.Count;
1105+
var combinedList = new List<T>(combinedCount);
1106+
// If checkDuplicates is set, so is rawText so the right exception can be thrown from check.
1107+
var check = checkDuplicates?.Invoke(combinedCount, rawText!);
1108+
foreach (var item in leftList)
1109+
{
1110+
check?.Invoke(item);
1111+
combinedList.Add(item);
1112+
}
1113+
foreach (var item in rightList)
1114+
{
1115+
check?.Invoke(item);
1116+
combinedList.Add(item);
1117+
}
1118+
return combinedList;
1119+
}
1120+
1121+
// Technically, the ParameterPolicies could probably be merged because it's a list, but it makes little sense to add policy
1122+
// for the same parameter in both the left and right part of the combined pattern. Defaults and Required values cannot be
1123+
// merged because the `TValue` is `object?`, but over-setting a Default or RequiredValue (which may not be in the parameter list)
1124+
// 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
1125+
// in PatternCore for when defaults come from both the `defaults` and `segments` param. `requiredValues` cannot be defined in
1126+
// `segments` so there's no equivalent to merging these until now.
1127+
static IReadOnlyDictionary<string, TValue> CombineDictionaries<TValue>(
1128+
IReadOnlyDictionary<string, TValue> leftDictionary,
1129+
IReadOnlyDictionary<string, TValue> rightDictionary,
1130+
string rawText,
1131+
string dictionaryName)
1132+
{
1133+
if (leftDictionary.Count is 0)
1134+
{
1135+
return rightDictionary;
1136+
}
1137+
if (rightDictionary.Count is 0)
1138+
{
1139+
return leftDictionary;
1140+
}
1141+
1142+
var combinedDictionary = new Dictionary<string, TValue>(leftDictionary.Count + rightDictionary.Count, StringComparer.OrdinalIgnoreCase);
1143+
foreach (var (key, value) in leftDictionary)
1144+
{
1145+
combinedDictionary.Add(key, value);
1146+
}
1147+
foreach (var (key, value) in rightDictionary)
1148+
{
1149+
if (combinedDictionary.TryGetValue(key, out var leftValue))
1150+
{
1151+
if (!Equals(leftValue, value))
1152+
{
1153+
throw new InvalidOperationException(Resources.FormatMapGroup_RepeatedDictionaryEntry(rawText, dictionaryName, key));
1154+
}
1155+
}
1156+
else
1157+
{
1158+
combinedDictionary.Add(key, value);
1159+
}
1160+
}
1161+
return combinedDictionary;
1162+
}
1163+
1164+
static Action<RoutePatternParameterPart> CheckDuplicateParameters(int parameterCount, string rawText)
1165+
{
1166+
var parameterNameSet = new HashSet<string>(parameterCount, StringComparer.OrdinalIgnoreCase);
1167+
return parameterPart =>
1168+
{
1169+
if (!parameterNameSet.Add(parameterPart.Name))
1170+
{
1171+
var errorText = Resources.FormatTemplateRoute_RepeatedParameter(parameterPart.Name);
1172+
throw new RoutePatternException(rawText, errorText);
1173+
}
1174+
};
1175+
}
1176+
1177+
var rawText = $"{left.RawText?.TrimEnd('/')}/{right.RawText?.TrimStart('/')}";
1178+
1179+
var parameters = CombineLists(left.Parameters, right.Parameters, CheckDuplicateParameters, rawText);
1180+
var pathSegments = CombineLists(left.PathSegments, right.PathSegments);
1181+
1182+
var defaults = CombineDictionaries(left.Defaults, right.Defaults, rawText, nameof(RoutePattern.Defaults));
1183+
var requiredValues = CombineDictionaries(left.RequiredValues, right.RequiredValues, rawText, nameof(RoutePattern.RequiredValues));
1184+
var parameterPolicies = CombineDictionaries(left.ParameterPolicies, right.ParameterPolicies, rawText, nameof(RoutePattern.ParameterPolicies));
1185+
1186+
return new RoutePattern(rawText, defaults, parameterPolicies, requiredValues, parameters, pathSegments);
1187+
}
1188+
10871189
private static RoutePatternParameterPolicyReference ParameterPolicyCore(string parameterPolicy)
10881190
{
10891191
return new RoutePatternParameterPolicyReference(parameterPolicy);

0 commit comments

Comments
 (0)