Skip to content

[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
wants to merge 3 commits into from
Closed
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
51 changes: 51 additions & 0 deletions src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -10,6 +11,39 @@

namespace Microsoft.AspNetCore.Builder;

internal class MetadataOnlyEndpointMetadata
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
internal class MetadataOnlyEndpointMetadata
internal sealed class MetadataOnlyEndpointMetadata

{

}

internal class MetadataOnlyEndpoint : Endpoint
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
internal class MetadataOnlyEndpoint : Endpoint
internal sealed class MetadataOnlyEndpoint : Endpoint

{
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>
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -120,3 +127,139 @@ public static IServiceCollection AddRouting(
return services;
}
}

internal class EndpointMetadataDecoratorMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
internal class EndpointMetadataDecoratorMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy
internal sealed class EndpointMetadataDecoratorMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy

{
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;
}
}
1 change: 1 addition & 0 deletions src/Http/Routing/src/PublicAPI.Unshipped.txt
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!
Original file line number Diff line number Diff line change
Expand Up @@ -343,4 +343,149 @@ public async Task CorsMiddleware_ConfiguredBeforeRouting_Throws()
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => server.CreateRequest("/").SendAsync("GET"));
Assert.Equal(CORSErrorMessage, ex.Message);
}

private class CustomMetadata
{
public string Whatever { get; set; }
}

private class CustomMetadata2
{
public string Whatever { get; set; }
}

[Fact]
public async Task CanAddMetadataOnlyToEndpoints()
{
// Arrange
using var host = new HostBuilder()
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(b =>
{
b.MapMetadata("/{**subpath}").WithMetadata(new CustomMetadata { Whatever = "This is on every endpoint now!" });
b.Map("/test/sub",
(HttpContext context) =>
{
Assert.Equal("This is on every endpoint now!", context.GetEndpoint()?.Metadata.GetMetadata<CustomMetadata>().Whatever);
return "Success!";
});
});
})
.UseTestServer();
})
.ConfigureServices(services =>
{
services.AddRouting();
})
.Build();

using var server = host.GetTestServer();

await host.StartAsync();

var response = await server.CreateRequest("/test/sub").SendAsync("GET");

response.EnsureSuccessStatusCode();
Assert.Equal($"Success!", await response.Content.ReadAsStringAsync());
}

[Fact]
public async Task CanNestMetadataOnlyEndpoints()
{
// Arrange
using var host = new HostBuilder()
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(b =>
{
b.MapMetadata("/{**subpath}").WithMetadata(new CustomMetadata { Whatever = "This is on every endpoint now!" });
b.MapMetadata("/sub/{**subpath}").WithMetadata(new CustomMetadata2 { Whatever = "Nested!" });
b.Map("/test/notsub",
(HttpContext context) =>
{
Assert.Equal("This is on every endpoint now!", context.GetEndpoint()?.Metadata.GetMetadata<CustomMetadata>().Whatever);
Assert.Null(context.GetEndpoint()?.Metadata.GetMetadata<CustomMetadata2>());
return "Success!";
});
b.Map("/sub/nested",
(HttpContext context) =>
{
Assert.Equal("This is on every endpoint now!", context.GetEndpoint()?.Metadata.GetMetadata<CustomMetadata>().Whatever);
Assert.Equal("Nested!", context.GetEndpoint()?.Metadata.GetMetadata<CustomMetadata2>().Whatever);
return "Success!";
});
});
})
.UseTestServer();
})
.ConfigureServices(services =>
{
services.AddRouting();
})
.Build();

using var server = host.GetTestServer();

await host.StartAsync();

var response = await server.CreateRequest("/test/notsub").SendAsync("GET");
response.EnsureSuccessStatusCode();
Assert.Equal($"Success!", await response.Content.ReadAsStringAsync());

response = await server.CreateRequest("/sub/nested").SendAsync("GET");
response.EnsureSuccessStatusCode();
Assert.Equal($"Success!", await response.Content.ReadAsStringAsync());
}

[Fact]
public async Task CanAddMetadataWithAuthZ()
{
// Arrange
using var host = new HostBuilder()
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder
.Configure(app =>
{
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(b =>
{
b.MapMetadata("/{**subpath}").WithMetadata(new CustomMetadata { Whatever = "This is on every endpoint now!" });
b.Map("/test/sub",
(HttpContext context) =>
{
Assert.Equal("This is on every endpoint now!", context.GetEndpoint()?.Metadata.GetMetadata<CustomMetadata>().Whatever);
Assert.NotNull(context.GetEndpoint()?.Metadata.GetMetadata<IAuthorizeData>());
return "Success!";
}).RequireAuthorization();
});
})
.UseTestServer();
})
.ConfigureServices(services =>
{
services.AddAuthorization(o => o.DefaultPolicy = new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build());
services.AddRouting();
})
.Build();

using var server = host.GetTestServer();

await host.StartAsync();

var response = await server.CreateRequest("/test/sub").SendAsync("GET");

response.EnsureSuccessStatusCode();
Assert.Equal($"Success!", await response.Content.ReadAsStringAsync());
}
}
Loading