You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The prefix should support slugs, route constraints, etc...
Allow adding metadata to a specified collection (group) of endpoints using a single API call.
Any endpoint that can be added to an IEndpointRouteBuilder can also be added to a group.
MapGet, MapPost, etc...
MapControllers
MapHub<THub>/MapConnections/MapConnectionHandler
MapFallbackToFile
MapHealthChecks
etc...
Existing IEndpointConventionBuilder extension methods can be applied to the entire group.
RequireAuthorization
RequireCors
WithGroupName
WithMetadata
WithName/WithDisplayName? Should this be an error?
etc...
The Action<EndpointBuilder> passed to the IEndpointConventionBuilder should be run per endpoint so previously applied metadata can be observed and possibly mutated.
We should make nested group structure observable at runtime via endpoint metadata.
API suggestions
MapGroup returns a GroupRouteBuilder
publicclassGroupRouteBuilder:IEndpointRouteBuilder,IEndpointConventionBuilder{publicRoutePatternGroupPrefix{get;}publicvoidConfigure<TBuilder>(Action<TBuilder>configure)whereTBuilder:IEndpointRouteBuilder;// The following wouldn't work because we cannot combine arbitrary IEndpointConventionBuilders//public TBuilder Configure<TBuilder>() where TBuilder : IEndpointRouteBuilder;}publicstaticclassGroupEndpointRouteBuilderExtensions{publicstatic GroupRouteBuilder MapGroup(thisIEndpointRouteBuilderendpoints,stringpattern);}publicinterfaceIGroupedEndpointConventionBuilder:IEndpointConventionBuilder{voidAddPrefix(Action<EndpointBuilder>convention);}// This would be implemented EndpointDataSources that support groupingpublicinterfaceIGroupedEndpointDataSource{IEnumerable<IGroupedEndpointConventionBuilder> ConventionBuilders {get;}}
varapp= WebApplication.Create(args);vargroup= app.MapGroup("/todos");
group.MapGet("/",(intid,TodoDbdb)=> db.ToListAsync());
group.MapGet("/{id}",(intid,TodoDbdb)=> db.GetAsync(id));
group.MapPost("/",(Todotodo,TodoDbdb)=> db.AddAsync(todo));varnestedGroup= group.MapGroup("/{org}");
nestedGroup.MapGet("/",(stringorg,TodoDbdb)=> db.Filter(todo => todo.Org ==org).ToListAsync());// RequireCors applies to both MapGet and MapPost
group.RequireCors("AllowAll");// Call extension methods tied to a specific type derived from IEndpointRouteBuilder
group.Configure<RouteHandlerBuilder>(builder =>{ builder.WithTags("todos");});// Would RequireCors apply to the following? I think it would, but this either way// it's confusing because it's not entirely clear what should happen.// Feedback: It would apply. Allow it.
group.MapDelete("/{id}",(intid,TodoDbdb)=> db.DeleteAsync(id));
varapp= WebApplication.Create(args);
app.MapGroup("/todos",group =>{ group.MapGet("/",(intid,TodoDbdb)=> db.ToListAsync()); group.MapGet("/{id}",(intid,TodoDbdb)=> db.GetAsync(id)); group.MapPost("/",(Todotodo,TodoDbdb)=> db.AddAsync(todo));// string org cannot be an argument to the configureGroup callback because that would require MapGet and other// IEndpointRouteBuilder extension methods to be repeatedly called fore every request. group.MapGroup("/{org}",nestedGroup =>{ nestedGroup.MapGet("/",(stringorg,TodoDbdb)=> db.Filter(todo => todo.Org ==org).ToListAsync());}).RequireAuthorization();}).RequireCors("AllowAll");
varapp= WebApplication.Create(args);vargroup=new GroupRouteBuilder("/todos");
group.MapGet("/",(intid,TodoDbdb)=> db.ToListAsync());
group.MapGet("/{id}",(intid,TodoDbdb)=> db.GetAsync(id));
group.MapPost("/",(Todotodo,TodoDbdb)=> db.AddAsync(todo));varnestedGroup=new GroupRouteBuilder("/{org}");
nestedGroup.MapGet("/",(stringorg,TodoDbdb)=> db.Filter(todo => todo.Org ==org).ToListAsync());
group.MapGroup(nestedGroup);
app.MapGroup(group).RequireCors("AllowAll");// Or, do we prefer to define the route prefix later?// In the following example, GroupRouteBuilder would just have a default constructor.
app.MapGroup("/todos", group).RequireCors("AllowAll");
app.MapGroup("/todos2", group).RequireCors("AllowAll");// Would we allow adding endpoints after calling MapGroup? If so, this has similar problems to option 1.
group.MapDelete("/{id}",(intid,TodoDbdb)=> db.DeleteAsync(id));// We'll also have to guard against recursion. The following would need to throw even if done before the call to app.MapGroup(group).//nestedGroup.MapGroup(group);
Open Questions
What do we do if any of the DataSources in GroupRouteBuilder.DataSources don't expose their IEndpointConventionBuilder?
What do we do if EndpointBuilder is not an RouteEndpointBuilder preventing us from prefixing the route pattern?
What happens if a change token fires on one of the DataSources in GroupRouteBuilder.DataSources?
Do we support adding endpoints to a group after startup when the token fires? What about metadata/filters?
Can we define a middleware that runs every time a route handler in a given group is hit before running the endpoint and similar middleware from inner groups?
How do we deal with middleware that checks the request path? Can we trim the group prefix from the path?
What if no endpoint is matched, but the middleware would have been terminal?
Do we automatically add the group template as a ApiExplorer "controller" (OpenAPI group tag)?
Early consensus is ... no
Would the group name be the template prefix?
This could lead to unusual characters in tag names.
What about tags?
Does this affect OpenAPI client generators?
What if the method is already defined in a class?
Do we continue to use the class as the tag name?
We wouldn't want to override the tag for MVC controllers.
Of the above API Proposals, what should we choose?
Option 1 and Option 2 together.
Problems
How do we deal with extension methods for specific IEndpointConventionBuilder implementations like RouteHandlerBuilder, ControllerActionEndpointConventionBuilder, HubEndpointConventionBuilder, etc.. With the exception of OpenApiRouteHandlerBuilderExtensions and now RouteHandlerFilterExtensions, it doesn't look like we ship many of these.
New versions of the extensions methods are created that work on a more common type.
This could make intellisense noisy.
These new versions could add Metadata via the IEndpointConventionBuilder interface.
Or maybe they could attempt to cast the IEndpointConventionBuilder to a the expected derived type. (e.g. cast to RouteHandlerBuilder to access RouteHandlerFilters)?
Or, we create multiple group builder types.
Is there someway to leverage generics here? I doesn't seem likely.
Even if we resolve the type issues, an IEndpointRouteBuilder has no ability to add metadata to an Endpoint added to an EndpointDataSource in DataSources today.
Can we expand ModelEndpointDataSource to allow access to it's IEndpointConventionBuilders?
MapController's ControllerActionEndpointDataSource already exposes this via its DefaultBuilder property.
These are internal types though. Do we define a public interface we attempt to as-cast EndpointDataSources to?
How do we support RouteHandlerBuilder.AddFilter()?
The IEndpointConventionBuilder in ModelEndpointDataSource is the inner DefaultEndpointConventionBuilder and not the RouteHandlerBuilder that wraps this. This would casting to RouteHandlerBuilder to access RouteHandlerFilters impossible.
Can we construct ModelEndpointDataSource differently to expose the outer RouteHandlerBuilder?
Appendix
Important Types
IEndpointRouteBuilder
/// <summary>/// Defines a contract for a route builder in an application. A route builder specifies the routes for/// an application./// </summary>publicinterfaceIEndpointRouteBuilder{/// <summary>/// Creates a new <see cref="IApplicationBuilder"/>./// </summary>/// <returns>The new <see cref="IApplicationBuilder"/>.</returns>
IApplicationBuilder CreateApplicationBuilder();/// <summary>/// Gets the <see cref="IServiceProvider"/> used to resolve services for routes./// </summary>IServiceProviderServiceProvider{get;}/// <summary>/// Gets the endpoint data sources configured in the builder./// </summary>ICollection<EndpointDataSource> DataSources {get;}}
EndpointDataSource
/// <summary>/// Provides a collection of <see cref="Endpoint"/> instances./// </summary>publicabstractclassEndpointDataSource{/// <summary>/// Gets a <see cref="IChangeToken"/> used to signal invalidation of cached <see cref="Endpoint"/>/// instances./// </summary>/// <returns>The <see cref="IChangeToken"/>.</returns>publicabstract IChangeToken GetChangeToken();/// <summary>/// Returns a read-only collection of <see cref="Endpoint"/> instances./// </summary>publicabstractIReadOnlyList<Endpoint> Endpoints {get;}}
/// <summary>/// Represents a logical endpoint in an application./// </summary>publicclassEndpoint{// ctor.../// <summary>/// Gets the informational display name of this endpoint./// </summary>publicstring?DisplayName{get;}/// <summary>/// Gets the collection of metadata associated with this endpoint./// </summary>publicEndpointMetadataCollectionMetadata{get;}/// <summary>/// Gets the delegate used to process requests for the endpoint./// </summary>publicRequestDelegate?RequestDelegate{get;}/// <summary>/// Returns a string representation of the endpoint./// </summary>publicoverridestring?ToString()=>DisplayName??base.ToString();}
RouteEndpoint
/// <summary>/// Represents an <see cref="Endpoint"/> that can be used in URL matching or URL generation./// </summary>publicsealedclassRouteEndpoint:Endpoint{// ctor.../// <summary>/// Gets the order value of endpoint./// </summary>/// <remarks>/// The order value provides absolute control over the priority/// of an endpoint. Endpoints with a lower numeric value of order have higher priority./// </remarks>publicintOrder{get;}/// <summary>/// Gets the <see cref="RoutePattern"/> associated with the endpoint./// </summary>publicRoutePatternRoutePattern{get;}}
EndpointBuilder
/// <summary>/// A base class for building an new <see cref="Endpoint"/>./// </summary>publicabstractclassEndpointBuilder{/// <summary>/// Gets or sets the delegate used to process requests for the endpoint./// </summary>publicRequestDelegate?RequestDelegate{get;set;}/// <summary>/// Gets or sets the informational display name of this endpoint./// </summary>publicstring?DisplayName{get;set;}/// <summary>/// Gets the collection of metadata associated with this endpoint./// </summary>publicIList<object> Metadata {get;}=newList<object>();/// <summary>/// Creates an instance of <see cref="Endpoint"/> from the <see cref="EndpointBuilder"/>./// </summary>/// <returns>The created <see cref="Endpoint"/>.</returns>publicabstract Endpoint Build();}
RouteEndpointBuilder
/// <summary>/// Supports building a new <see cref="RouteEndpoint"/>./// </summary>publicsealedclassRouteEndpointBuilder:EndpointBuilder{/// <summary>/// Gets or sets the <see cref="RoutePattern"/> associated with this endpoint./// </summary>publicRoutePatternRoutePattern{get;set;}/// <summary>/// Gets or sets the order assigned to the endpoint./// </summary>publicintOrder{get;set;}// ctors.../// <inheritdoc />publicoverride Endpoint Build(){if(RequestDelegate isnull){thrownew InvalidOperationException($"{nameof(RequestDelegate)} must be specified to construct a {nameof(RouteEndpoint)}.");}varrouteEndpoint=new RouteEndpoint(
RequestDelegate,
RoutePattern,
Order,new EndpointMetadataCollection(Metadata),
DisplayName);returnrouteEndpoint;}}
IEndpointConventionBuilder
/// <summary>/// Builds conventions that will be used for customization of <see cref="EndpointBuilder"/> instances./// </summary>/// <remarks>/// This interface is used at application startup to customize endpoints for the application./// </remarks>publicinterfaceIEndpointConventionBuilder{/// <summary>/// Adds the specified convention to the builder. Conventions are used to customize <see cref="EndpointBuilder"/> instances./// </summary>/// <param name="convention">The convention to add to the builder.</param>voidAdd(Action<EndpointBuilder>convention);}
DefaultEndpointConventionBuilder
internalclassDefaultEndpointConventionBuilder:IEndpointConventionBuilder{internalEndpointBuilderEndpointBuilder{get;}privateList<Action<EndpointBuilder>>?_conventions;publicDefaultEndpointConventionBuilder(EndpointBuilderendpointBuilder){EndpointBuilder=endpointBuilder;_conventions=new();}publicvoidAdd(Action<EndpointBuilder>convention){varconventions= _conventions;if(conventions isnull){thrownew InvalidOperationException("Conventions cannot be added after building the endpoint");}
conventions.Add(convention);}public Endpoint Build(){// Only apply the conventions oncevarconventions= Interlocked.Exchange(ref _conventions,null);if(conventions is not null){foreach(var convention in conventions){
convention(EndpointBuilder);}}return EndpointBuilder.Build();}}
EndpointRouteBuilderExtensions.Map
privatestatic RouteHandlerBuilder Map(thisIEndpointRouteBuilderendpoints,RoutePatternpattern,Delegatehandler,booldisableInferBodyFromParameters){// ...varbuilder=new RouteEndpointBuilder(
pattern,
defaultOrder){DisplayName= pattern.RawText ?? pattern.DebuggerToString(),};// ...vardataSource= endpoints.DataSources.OfType<ModelEndpointDataSource>().FirstOrDefault();if(dataSource isnull){dataSource=new ModelEndpointDataSource();
endpoints.DataSources.Add(dataSource);}varrouteHandlerBuilder=new RouteHandlerBuilder(dataSource.AddEndpointBuilder(builder));
routeHandlerBuilder.Add(endpointBuilder =>{// ...// filteredRequestDelegateResult.RequestDelegate is derived from routeHandlerBuilder which is captured// by the lambda. endpointBuilder.RequestDelegate = filteredRequestDelegateResult.RequestDelegate;});returnrouteHandlerBuilder;}