-
Notifications
You must be signed in to change notification settings - Fork 10.3k
[WIP] Support metadata only endpoints #46536
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
Closed
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -2,6 +2,7 @@ | |||||
// The .NET Foundation licenses this file to you under the MIT license. | ||||||
|
||||||
using System.Diagnostics.CodeAnalysis; | ||||||
using System.Linq; | ||||||
using Microsoft.AspNetCore.Http; | ||||||
using Microsoft.AspNetCore.Routing; | ||||||
using Microsoft.AspNetCore.Routing.Patterns; | ||||||
|
@@ -10,6 +11,39 @@ | |||||
|
||||||
namespace Microsoft.AspNetCore.Builder; | ||||||
|
||||||
internal class MetadataOnlyEndpointMetadata | ||||||
{ | ||||||
|
||||||
} | ||||||
|
||||||
internal class MetadataOnlyEndpoint : Endpoint | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
{ | ||||||
public static readonly RequestDelegate NoOpRequestDelegate = (ctx) => Task.CompletedTask; | ||||||
|
||||||
public MetadataOnlyEndpoint(Endpoint endpoint) | ||||||
: base(null, endpoint.Metadata, GetDisplayName(endpoint)) | ||||||
{ | ||||||
|
||||||
} | ||||||
|
||||||
public MetadataOnlyEndpoint(Endpoint endpoint, IReadOnlyList<object> metadata) | ||||||
: base(null, new(endpoint.Metadata.Union(metadata)), GetDisplayName(endpoint)) | ||||||
{ | ||||||
|
||||||
} | ||||||
|
||||||
public static bool IsMetadataOnlyEndpoint(Endpoint endpoint) => | ||||||
ReferenceEquals(endpoint.RequestDelegate, NoOpRequestDelegate); | ||||||
|
||||||
private static string GetDisplayName(Endpoint endpoint) | ||||||
{ | ||||||
var suffix = $"[{nameof(MetadataOnlyEndpoint)}]"; | ||||||
return !string.IsNullOrEmpty(endpoint.DisplayName) | ||||||
? endpoint.DisplayName + " " + suffix | ||||||
: suffix; | ||||||
} | ||||||
} | ||||||
|
||||||
/// <summary> | ||||||
/// Provides extension methods for <see cref="IEndpointRouteBuilder"/> to add endpoints. | ||||||
/// </summary> | ||||||
|
@@ -154,6 +188,23 @@ public static IEndpointConventionBuilder MapMethods( | |||||
return endpoints.Map(RoutePatternFactory.Parse(pattern), requestDelegate, httpMethods); | ||||||
} | ||||||
|
||||||
/// <summary> | ||||||
/// Adds a <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that adds the provided metadata items to | ||||||
/// any <see cref="RouteEndpoint"/> mapped to HTTP requests for the specified pattern. | ||||||
/// </summary> | ||||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> builder.</param> | ||||||
/// <param name="pattern">The route pattern.</param> | ||||||
/// <param name="items">A collection of metadata items.</param> | ||||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> that can be used to further customize the endpoint.</returns> | ||||||
public static IEndpointConventionBuilder MapMetadata(this IEndpointRouteBuilder endpoints, | ||||||
[StringSyntax("Route")] string pattern, | ||||||
params object[] items) | ||||||
{ | ||||||
return endpoints.Map(pattern, MetadataOnlyEndpoint.NoOpRequestDelegate) | ||||||
.WithMetadata(new MetadataOnlyEndpointMetadata()) | ||||||
.WithMetadata(items); | ||||||
} | ||||||
|
||||||
/// <summary> | ||||||
/// Adds a <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that matches HTTP requests | ||||||
/// for the specified pattern. | ||||||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -2,6 +2,11 @@ | |||||
// The .NET Foundation licenses this file to you under the MIT license. | ||||||
|
||||||
using System.Collections.ObjectModel; | ||||||
using Microsoft.AspNetCore.Builder; | ||||||
using Microsoft.AspNetCore.Http; | ||||||
using System.Diagnostics; | ||||||
using System.Linq; | ||||||
using System.Runtime.CompilerServices; | ||||||
using Microsoft.AspNetCore.Routing; | ||||||
using Microsoft.AspNetCore.Routing.Internal; | ||||||
using Microsoft.AspNetCore.Routing.Matching; | ||||||
|
@@ -88,6 +93,8 @@ public static IServiceCollection AddRouting(this IServiceCollection services) | |||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, HttpMethodMatcherPolicy>()); | ||||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, HostMatcherPolicy>()); | ||||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, AcceptsMatcherPolicy>()); | ||||||
// TODO: Make this 1st class instead | ||||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, EndpointMetadataDecoratorMatcherPolicy>()); | ||||||
|
||||||
// | ||||||
// Misc infrastructure | ||||||
|
@@ -120,3 +127,139 @@ public static IServiceCollection AddRouting( | |||||
return services; | ||||||
} | ||||||
} | ||||||
|
||||||
internal class EndpointMetadataDecoratorMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
{ | ||||||
private readonly ConditionalWeakTable<Endpoint, Endpoint> _endpointsCache = new(); | ||||||
|
||||||
public override int Order { get; } | ||||||
|
||||||
public bool AppliesToEndpoints(IReadOnlyList<Endpoint> endpoints) | ||||||
{ | ||||||
return endpoints.Any(e => MetadataOnlyEndpoint.IsMetadataOnlyEndpoint(e) | ||||||
&& e.Metadata.GetMetadata<MetadataOnlyEndpointMetadata>() is not null); | ||||||
} | ||||||
|
||||||
public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) | ||||||
{ | ||||||
// Try to find cache entry for single candidate | ||||||
var firstCandidate = candidates[0]; | ||||||
Endpoint? cachedEndpoint; | ||||||
if (candidates.Count == 1 && _endpointsCache.TryGetValue(firstCandidate.Endpoint, out cachedEndpoint)) | ||||||
{ | ||||||
// Only use the current request's route values if the candidate match is an actual endpoint | ||||||
var values = !MetadataOnlyEndpoint.IsMetadataOnlyEndpoint(firstCandidate.Endpoint) | ||||||
? firstCandidate.Values | ||||||
: null; | ||||||
candidates.ReplaceEndpoint(0, cachedEndpoint, values); | ||||||
return Task.CompletedTask; | ||||||
} | ||||||
|
||||||
// Fallback to looping through all candiates | ||||||
Endpoint? firstMetadataOnlyEndpoint = null; | ||||||
// PERF: Use a list type optimized for small item counts instead | ||||||
List<Endpoint>? metadataOnlyEndpoints = null; | ||||||
var replacementCandidateIndex = -1; | ||||||
var realEndpointCandidateCount = 0; | ||||||
|
||||||
for (int i = 0; i < candidates.Count; i++) | ||||||
{ | ||||||
var candidate = candidates[i]; | ||||||
|
||||||
if (MetadataOnlyEndpoint.IsMetadataOnlyEndpoint(candidate.Endpoint)) | ||||||
{ | ||||||
if (firstMetadataOnlyEndpoint is null) | ||||||
{ | ||||||
firstMetadataOnlyEndpoint = candidate.Endpoint; | ||||||
} | ||||||
else | ||||||
{ | ||||||
if (metadataOnlyEndpoints is null) | ||||||
{ | ||||||
metadataOnlyEndpoints = new List<Endpoint> | ||||||
{ | ||||||
firstMetadataOnlyEndpoint | ||||||
}; | ||||||
} | ||||||
metadataOnlyEndpoints.Add(candidate.Endpoint); | ||||||
} | ||||||
if (realEndpointCandidateCount == 0 && replacementCandidateIndex == -1) | ||||||
{ | ||||||
// Only capture index of first metadata only endpoint as candidate replacement | ||||||
replacementCandidateIndex = i; | ||||||
} | ||||||
} | ||||||
else | ||||||
{ | ||||||
realEndpointCandidateCount++; | ||||||
if (realEndpointCandidateCount == 1) | ||||||
{ | ||||||
// Only first real endpoint is a candidate | ||||||
replacementCandidateIndex = i; | ||||||
} | ||||||
} | ||||||
} | ||||||
|
||||||
Debug.Assert(firstMetadataOnlyEndpoint is not null); | ||||||
Debug.Assert(metadataOnlyEndpoints?.Count >= 1 || firstMetadataOnlyEndpoint is not null); | ||||||
Debug.Assert(replacementCandidateIndex >= 0); | ||||||
|
||||||
var activeCandidate = candidates[replacementCandidateIndex]; | ||||||
var activeEndpoint = (RouteEndpoint)activeCandidate.Endpoint; | ||||||
|
||||||
// TODO: Review what the correct behavior is if there is more than 1 real endpoint candidate. | ||||||
|
||||||
if (realEndpointCandidateCount is 0 or 1 && activeEndpoint is not null) | ||||||
{ | ||||||
Endpoint? replacementEndpoint = null; | ||||||
|
||||||
// Check cache for replacement endpoint | ||||||
if (!_endpointsCache.TryGetValue(activeEndpoint, out replacementEndpoint)) | ||||||
{ | ||||||
// Not found in cache so build up the replacement endpoint | ||||||
IReadOnlyList<object> decoratedMetadata = metadataOnlyEndpoints is not null | ||||||
? metadataOnlyEndpoints.SelectMany(e => e.Metadata).ToList() | ||||||
: firstMetadataOnlyEndpoint.Metadata; | ||||||
|
||||||
if (realEndpointCandidateCount == 1) | ||||||
{ | ||||||
var routeEndpointBuilder = new RouteEndpointBuilder(activeEndpoint.RequestDelegate!, activeEndpoint.RoutePattern, activeEndpoint.Order); | ||||||
|
||||||
routeEndpointBuilder.DisplayName = activeEndpoint.DisplayName; | ||||||
|
||||||
// Add metadata from metadata-only endpoint candidates | ||||||
foreach (var metadata in decoratedMetadata) | ||||||
{ | ||||||
routeEndpointBuilder.Metadata.Add(metadata); | ||||||
} | ||||||
|
||||||
// Add metadata from active endpoint | ||||||
if (realEndpointCandidateCount > 0) | ||||||
{ | ||||||
foreach (var metadata in activeEndpoint.Metadata) | ||||||
{ | ||||||
if (metadata is not null) | ||||||
{ | ||||||
routeEndpointBuilder.Metadata.Add(metadata); | ||||||
} | ||||||
} | ||||||
} | ||||||
|
||||||
replacementEndpoint = routeEndpointBuilder.Build(); | ||||||
} | ||||||
else | ||||||
{ | ||||||
replacementEndpoint = new MetadataOnlyEndpoint(activeEndpoint, decoratedMetadata); | ||||||
} | ||||||
|
||||||
_endpointsCache.Add(activeEndpoint, replacementEndpoint); | ||||||
} | ||||||
var values = realEndpointCandidateCount == 1 ? activeCandidate.Values : null; | ||||||
|
||||||
// Replace the endpoint | ||||||
candidates.ReplaceEndpoint(replacementCandidateIndex, replacementEndpoint, values); | ||||||
} | ||||||
|
||||||
return Task.CompletedTask; | ||||||
} | ||||||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
#nullable enable | ||
Microsoft.AspNetCore.Routing.RouteHandlerServices | ||
static Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapMetadata(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, params object![]! items) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! | ||
static Microsoft.AspNetCore.Routing.RouteHandlerServices.Map(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Delegate! handler, System.Collections.Generic.IEnumerable<string!>! httpMethods, System.Func<System.Reflection.MethodInfo!, Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions?, Microsoft.AspNetCore.Http.RequestDelegateMetadataResult!>! populateMetadata, System.Func<System.Delegate!, Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions!, Microsoft.AspNetCore.Http.RequestDelegateMetadataResult?, Microsoft.AspNetCore.Http.RequestDelegateResult!>! createRequestDelegate) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.