-
Notifications
You must be signed in to change notification settings - Fork 10.3k
[Route Groups] Support AddFilter, WithOpenApi and other additive conventions #41427
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
Comments
Design meeting notes
Goals
1: Here's a hacky prototype. It currently rebuilds the RequestDelegate every time, but could be updated |
I think the above API is a better solution than the hacky prototype I linked to in the last comment even if we cached the |
Background and MotivationMany metadata-adding extension methods like However, It should be possible to apply both route handler filters an OpenAPI metadata using groups. And anything done at an outer group level should have a lower precedence than anything specified directly on the endpoint. This means ideally the filters and To support this, I added a new virtual The upside of moving this logic into RouteEndointDataSource other than general cleanliness, is that as a custom EndpointDataSource, it can override EndpointDataSource.GetGroupedEndpoints(GroupContext) and inspect the full group prefix and all the group metadata before calling RequestDelegateFactory.Create() or running any filters. Even though Proposed APInamespace Microsoft.AspNetCore.Http;
public sealed class RequestDelegateFactoryOptions
{
- public IEnumerable<object>? InitialEndpointMetadata { get; init; }
+ public IList<object>? EndpointMetadata { get; init; }
}
namespace Microsoft.AspNetCore.Routing;
public sealed class RouteGroupBuilder : IEndpointRouteBuilder, IEndpointConventionBuilder
{
- // It was too easy for this to get out of sync with RouteGroupContext.Prefix in real life given RouteGroupBuilder decorators.
- public RoutePattern GroupPrefix { get; }
}
+public sealed class RouteGroupContext
+{
+ public RouteGroupContext(RoutePattern prefix, IReadOnlyList<Action<EndpointBuilder>> conventions, IServiceProvider applicationServices);
+
+ public RoutePattern Prefix { get; }
+ public IReadOnlyList<Action<EndpointBuilder>> Conventions { get; }
+ public IServiceProvider ApplicationServices { get; }
+}
public abstract class EndpointDataSource
{
+ // What do we think about the name? We could also separate out the parameters, but this would mean breaking
+ // decorators every time we add a parameter. This is also something that will likely never be called by user code
+ // unless they were decorating or implementing their own version of RouteGroupBuilder.
+ // e.g. GetGroupedEndpoints(prefix, conventions, applicationServices).
+ public virtual IReadOnlyList<Endpoint> GetGroupedEndpoints(RouteGroupContext context);
}
// The composite data source was never disposing change token registrations previously.
// It also didn't even listen to change tokens from EndponitDataSources added to the observable collection late.
-public sealed class CompositeEndpointDataSource : EndpointDataSource
+public sealed class CompositeEndpointDataSource : EndpointDataSource, IDisposable
{
+ public override IReadOnlyList<Endpoint> GetGroupedEndpoints(RouteGroupContext context);
+ public void Dispose();
}
// REVIEW: Renamed `AddFilter` to `AddRouteHandlerFilter` now that it will show up in intellisense after `MapControllers()` and stuff
// Can we come up with a better name? `AddRouteFiter`? Should we rename `RouteHandlerFilterExtensions`?
public static class RouteHandlerFilterExtensions
{
- public static RouteHandlerBuilder AddFilter(this RouteHandlerBuilder builder, IRouteHandlerFilter filter);
+ public static TBuilder AddRouteHandlerFilter<TBuilder>(this TBuilder builder, IRouteHandlerFilter filter) where TBuilder : IEndpointConventionBuilder;
- public static RouteHandlerBuilder AddFilter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TFilterType>(this RouteHandlerBuilder builder)
- where TFilterType : IRouteHandlerFilter;
+ public static RouteHandlerBuilder AddRouteHandlerFilter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TFilterType>(this RouteHandlerBuilder builder)
+ where TFilterType : IRouteHandlerFilter;
+ public static TBuilder AddRouteHandlerFilter<TBuilder, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TFilterType>(this TBuilder builder)
+ where TBuilder : IEndpointConventionBuilder
+ where TFilterType : IRouteHandlerFilter;
+ public static RouteGroupBuilder AddRouteHandlerFilter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TFilterType>(this RouteGroupBuilder builder)
+ where TFilterType : IRouteHandlerFilter;
- public static RouteHandlerBuilder AddFilter(this RouteHandlerBuilder builder, Func<RouteHandlerInvocationContext, RouteHandlerFilterDelegate, ValueTask<object?>> routeHandlerFilter);
+ public static TBuilder AddRouteHandlerFilter<TBuilder>(this TBuilder builder, Func<RouteHandlerInvocationContext, RouteHandlerFilterDelegate, ValueTask<object?>> routeHandlerFilter)
+ where TBuilder : IEndpointConventionBuilder;
- public static RouteHandlerBuilder AddFilter(this RouteHandlerBuilder builder, Func<RouteHandlerContext, RouteHandlerFilterDelegate, RouteHandlerFilterDelegate> filterFactory);
+ public static TBuilder AddRouteHandlerFilter<TBuilder>(this TBuilder builder, Func<RouteHandlerContext, RouteHandlerFilterDelegate, RouteHandlerFilterDelegate> filterFactory)
+ where TBuilder : IEndpointConventionBuilder;
}
namespace Microsoft.AspNetCore.OpenApi;
// REVIEW: I decided `WithOpenApi()` isn't too confusing of a name even though it shows up in places it won't work now
// Do we agree this doesn't need renamed?
public static class OpenApiRouteHandlerBuilderExtensions
{
- public static RouteHandlerBuilder WithOpenApi(this RouteHandlerBuilder builder);
+ public static TBuilder WithOpenApi<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder;
- public static RouteHandlerBuilder WithOpenApi(this RouteHandlerBuilder builder, Func<OpenApiOperation, OpenApiOperation> configureOperation);
+ public static TBuilder WithOpenApi<TBuilder>(this TBuilder builder, Func<OpenApiOperation, OpenApiOperation> configureOperation);
} Usage Examplesstring GetString() => "Foo";
static void WithLocalSummary(RouteHandlerBuilder builder)
{
builder.WithOpenApi(operation =>
{
operation.Summary += $" | Local Summary | 200 Status Response Content-Type: {operation.Responses["200"].Content.Keys.Single()}";
return operation;
});
}
WithLocalSummary(builder.MapDelete("/root", GetString));
var outerGroup = builder.MapGroup("/outer");
var innerGroup = outerGroup.MapGroup("/inner");
WithLocalSummary(outerGroup.MapDelete("/outer-a", GetString));
// The order WithOpenApi() is relative to the MapDelete() methods does not matter.
outerGroup.WithOpenApi(operation =>
{
operation.Summary = "Outer Group Summary";
return operation;
});
WithLocalSummary(outerGroup.MapDelete("/outer-b", GetString));
WithLocalSummary(innerGroup.MapDelete("/inner-a", GetString));
innerGroup.WithOpenApi(operation =>
{
operation.Summary += " | Inner Group Summary";
return operation;
});
WithLocalSummary(innerGroup.MapDelete("/inner-b", GetString));
var summaries = builder.DataSources.SelectMany(ds => ds.Endpoints).Select(e => e.Metadata.GetMetadata<OpenApiOperation>().Summary).ToArray();
Assert.Equal(5, summaries.Length);
Assert.Contains(" | Local Summary | 200 Status Response Content-Type: text/plain", summaries);
Assert.Contains("Outer Group Summary | Local Summary | 200 Status Response Content-Type: text/plain", summaries);
Assert.Contains("Outer Group Summary | Local Summary | 200 Status Response Content-Type: text/plain", summaries);
Assert.Contains("Outer Group Summary | Inner Group Summary | Local Summary | 200 Status Response Content-Type: text/plain", summaries);
Assert.Contains("Outer Group Summary | Inner Group Summary | Local Summary | 200 Status Response Content-Type: text/plain", summaries); string PrintId(int id) => $"ID: {id}";
static void AddParamIncrementingFilter(IEndpointConventionBuilder builder)
{
builder.AddRouteHandlerFilter(async (context, next) =>
{
context.Arguments[0] = ((int)context.Arguments[0]!) + 1;
return await next(context);
});
}
AddParamIncrementingFilter(builder.Map("/{id}", PrintId));
var outerGroup = builder.MapGroup("/outer");
AddParamIncrementingFilter(outerGroup);
AddParamIncrementingFilter(outerGroup.Map("/{id}", PrintId));
var innerGroup = outerGroup.MapGroup("/inner");
AddParamIncrementingFilter(innerGroup);
AddParamIncrementingFilter(innerGroup.Map("/{id}", PrintId));
var endpoints = builder.DataSources
.SelectMany(ds => ds.Endpoints)
.ToDictionary(e => ((RouteEndpoint)e).RoutePattern.RawText!);
Assert.Equal(3, endpoints.Count);
// For each layer of grouping, another filter is applies which increments the expectedId by 1 each time.
await AssertIdAsync(endpoints["/{id}"], expectedPattern: "/{id}", expectedId: 3);
await AssertIdAsync(endpoints["/outer/{id}"], expectedPattern: "/outer/{id}", expectedId: 4);
await AssertIdAsync(endpoints["/outer/inner/{id}"], expectedPattern: "/outer/inner/{id}", expectedId: 5); Alternative DesignsWhen discussing the original desiging in #41427 (comment), I had originally planned to wait for the request to call Furthermore, This also allows other custom RisksThis preserves the prior (in .NET 7 previews at least) behavior of calling |
Thank you for submitting this for API review. This will be reviewed by @dotnet/aspnet-api-review at the next meeting of the ASP.NET Core API Review group. Please ensure you take a look at the API review process documentation and ensure that:
|
API Review Notes:
namespace Microsoft.AspNetCore.Http;
public sealed class RequestDelegateFactoryOptions
{
- public IEnumerable<object>? InitialEndpointMetadata { get; init; }
+ public IList<object>? EndpointMetadata { get; init; }
}
namespace Microsoft.AspNetCore.Routing;
public sealed class RouteGroupBuilder : IEndpointRouteBuilder, IEndpointConventionBuilder
{
- // It was too easy for this to get out of sync with RouteGroupContext.Prefix in real life given RouteGroupBuilder decorators.
- public RoutePattern GroupPrefix { get; }
}
+public sealed class RouteGroupContext
+{
+ public RouteGroupContext(RoutePattern prefix, IReadOnlyList<Action<EndpointBuilder>> conventions, IServiceProvider applicationServices);
+
+ public RoutePattern Prefix { get; }
+ public IReadOnlyList<Action<EndpointBuilder>> Conventions { get; }
+ public IServiceProvider ApplicationServices { get; }
+}
public abstract class EndpointDataSource
{
+ public virtual IReadOnlyList<Endpoint> GetEndpointGroup(RouteGroupContext context);
}
-public sealed class CompositeEndpointDataSource : EndpointDataSource
+public sealed class CompositeEndpointDataSource : EndpointDataSource, IDisposable
{
+ public override IReadOnlyList<Endpoint> GetEndpointGroup(RouteGroupContext context);
+ public void Dispose();
}
public static class RouteHandlerFilterExtensions
{
- public static RouteHandlerBuilder AddFilter(this RouteHandlerBuilder builder, IRouteHandlerFilter filter);
+ public static TBuilder AddRouteHandlerFilter<TBuilder>(this TBuilder builder, IRouteHandlerFilter filter) where TBuilder : IEndpointConventionBuilder;
- public static RouteHandlerBuilder AddFilter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TFilterType>(this RouteHandlerBuilder builder)
- where TFilterType : IRouteHandlerFilter;
+ public static RouteHandlerBuilder AddRouteHandlerFilter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TFilterType>(this RouteHandlerBuilder builder)
+ where TFilterType : IRouteHandlerFilter;
+ public static TBuilder AddRouteHandlerFilter<TBuilder, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TFilterType>(this TBuilder builder)
+ where TBuilder : IEndpointConventionBuilder
+ where TFilterType : IRouteHandlerFilter;
+ public static RouteGroupBuilder AddRouteHandlerFilter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TFilterType>(this RouteGroupBuilder builder)
+ where TFilterType : IRouteHandlerFilter;
- public static RouteHandlerBuilder AddFilter(this RouteHandlerBuilder builder, Func<RouteHandlerInvocationContext, RouteHandlerFilterDelegate, ValueTask<object?>> routeHandlerFilter);
+ public static TBuilder AddRouteHandlerFilter<TBuilder>(this TBuilder builder, Func<RouteHandlerInvocationContext, RouteHandlerFilterDelegate, ValueTask<object?>> routeHandlerFilter)
+ where TBuilder : IEndpointConventionBuilder;
- public static RouteHandlerBuilder AddFilter(this RouteHandlerBuilder builder, Func<RouteHandlerContext, RouteHandlerFilterDelegate, RouteHandlerFilterDelegate> filterFactory);
+ public static TBuilder AddRouteHandlerFilter<TBuilder>(this TBuilder builder, Func<RouteHandlerContext, RouteHandlerFilterDelegate, RouteHandlerFilterDelegate> filterFactory)
+ where TBuilder : IEndpointConventionBuilder;
}
namespace Microsoft.AspNetCore.OpenApi;
public static class OpenApiRouteHandlerBuilderExtensions
{
- public static RouteHandlerBuilder WithOpenApi(this RouteHandlerBuilder builder);
+ public static TBuilder WithOpenApi<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder;
- public static RouteHandlerBuilder WithOpenApi(this RouteHandlerBuilder builder, Func<OpenApiOperation, OpenApiOperation> configureOperation);
+ public static TBuilder WithOpenApi<TBuilder>(this TBuilder builder, Func<OpenApiOperation, OpenApiOperation> configureOperation);
} API Approved! |
We want to rename the new namespace Microsoft.AspNetCore.Http;
public abstract class EndpointDataSource
{
- public virtual IReadOnlyList<Endpoint> GetEndpointGroup(RouteGroupContext context);
+ public virtual IReadOnlyList<Endpoint> GetGroupedEndpoints(RouteGroupContext context);
} |
Thank you for submitting this for API review. This will be reviewed by @dotnet/aspnet-api-review at the next meeting of the ASP.NET Core API Review group. Please ensure you take a look at the API review process documentation and ensure that:
|
Keeping open to track reacting to feedback in #42195 and for the API proposal in #41427 (comment) |
API review notes:
namespace Microsoft.AspNetCore.Http;
public abstract class EndpointDataSource
{
- public virtual IReadOnlyList<Endpoint> GetEndpointGroup(RouteGroupContext context);
+ public virtual IReadOnlyList<Endpoint> GetGroupedEndpoints(RouteGroupContext context);
} API approved! |
Reopening to track |
Many metadata-adding extension methods like
RequireCors()
,RequireAuthorization()
,WithGroupName()
, etc... can already be applied to an entire group using theGroupRouteBuilder
added in #36007.However, extension methods like
AddFilter
forRouteHandlerBuilder
don't work becauseGroupRouteBuilder
is not aRouteHandlerBuilder
. This makes it impossible to apply a route handler filter to a group of endpoints today.Describe the solution you'd like
Originally there was a proposal to add an
public void OfBuilder<T>(Action<T> configureBuilder) where T : IEndpointConventionBuilder;
method toGroupRouteBuilder
that would support types likeRouteHandlerBuilder
, but that wasn't approved for the initial design. This is partially due to the ugliness of using anAction<T>
to use theRouteHandlerBuilder
and the ugliness of theIGroupEndpointDataSource
interfaces required to support the API.Now I'm thinking it could be some abstract static interface method on the type implementing IEndpointConventionBuilder (e.g. RouteHandlerBuilder) could construct itself using an arbitrary IEndpointConventionBuilder and rely on EndpiontBuilder.Metadata to keep state.
The trick is going to be using EndpointBuilder.Metadata to store the RouteHanderFilterFactories and to read them so it can be rehydrated rather than assuming that the RouteHandlerBuilder returned from the initial call to MapGet/MapPost/etc... is the only RouteHandlerBuilder adding filters to a given endpoint.
Figuring out exactly how we can read the metadata late enough is going to be tough. We need to create a RequestDelegate before adding group metadata given the current design. We might want to build the final handler the first time Endpoint.RequestDelegate is called so we can read the final endpoint metadata off of the HttpContext, then create a new RequestDelegate with all the filters and call that from then on out for the new endpoint. This is probably better than locking routing globally on RequestDelegateFactory.Create like we do today in .NET 7 previews.
The text was updated successfully, but these errors were encountered: