MintPlayer.AspNetCore.Endpoints
11.4.0-rc.0
dotnet add package MintPlayer.AspNetCore.Endpoints --version 11.4.0-rc.0
NuGet\Install-Package MintPlayer.AspNetCore.Endpoints -Version 11.4.0-rc.0
<PackageReference Include="MintPlayer.AspNetCore.Endpoints" Version="11.4.0-rc.0" />
<PackageVersion Include="MintPlayer.AspNetCore.Endpoints" Version="11.4.0-rc.0" />
<PackageReference Include="MintPlayer.AspNetCore.Endpoints" />
paket add MintPlayer.AspNetCore.Endpoints --version 11.4.0-rc.0
#r "nuget: MintPlayer.AspNetCore.Endpoints, 11.4.0-rc.0"
#:package MintPlayer.AspNetCore.Endpoints@11.4.0-rc.0
#addin nuget:?package=MintPlayer.AspNetCore.Endpoints&version=11.4.0-rc.0&prerelease
#tool nuget:?package=MintPlayer.AspNetCore.Endpoints&version=11.4.0-rc.0&prerelease
MintPlayer.AspNetCore.Endpoints
One class per endpoint for ASP.NET Core Minimal APIs. A source generator discovers the classes and writes the mapping, typed links, OpenAPI metadata and a cross-assembly contract a typed client can be generated from. Endpoints get constructor injection, content negotiation and explicit route/query binding. Targets .NET 10 and .NET 11.
| Package | What it is |
|---|---|
MintPlayer.AspNetCore.Endpoints |
Runtime library + generator + code fix. Reference this in the server. |
MintPlayer.AspNetCore.Endpoints.Abstractions |
The interfaces and attributes (IGetEndpoint, IEndpointGroup, [MemberOf<T>], [RouteParam], …). The main package references it. |
MintPlayer.AspNetCore.Endpoints.Generator |
Analyzer-only (no FrameworkReference). Reference this in a typed client. |
dotnet add package MintPlayer.AspNetCore.Endpoints
Requirements
The generator needs a compiler with Roslyn 5.9 or newer: the .NET SDK 10.0.400+ or 11.x, or
Visual Studio 2026. Older compilers are not supported. The generator ships in the package's
analyzers/dotnet/roslyn5.9/cs folder, so an older compiler (the .NET SDK 10.0.1xx ships Roslyn 5.0)
skips it without a word, and the build fails on the code it should have generated. The first error is
the missing mapping method:
Program.cs(5,5): error CS1061: 'WebApplication' does not contain a definition for 'MapConsumerEndpoints' and no accessible extension method 'MapConsumerEndpoints' accepting a first argument of type 'WebApplication' could be found (are you missing a using directive or an assembly reference?)
A project with typed endpoints fails one step earlier, on the base class the generator did not add:
Program.cs(12,37): error CS0535: 'GetThing' does not implement interface member 'IEndpoint.HandleAsync(HttpContext)'
Program.cs(18,39): error CS0115: 'GetThing.HandleAsync(CancellationToken)': no suitable method found to override
If you see these, update the SDK (or pin a newer one in global.json); nothing in your code is wrong.
Upgrading to 11.4 (breaking)
Configuretakes the service provider.Configure(RouteGroupBuilder)andConfigure(RouteHandlerBuilder)now take a second parameter,IServiceProvider services.- An implicit
public static void Configure(RouteGroupBuilder group)of the old shape still compiles, as an ordinary method, but it is never called. MPEP035 (Error) reports it, and its code fix adds the parameter, with Fix All. - An explicit implementation of the old hook (
static void IEndpointGroup.Configure(RouteGroupBuilder group)) fails withCS0539. MPEP035 offers the same fix. - A non-public helper named
Configureis not a hook and is not flagged.
- An implicit
- A class is a group or an endpoint, not both. A class implementing
IEndpointGroupand an endpoint interface now fails with MPEP036 (Error). Split it in two and join the endpoint to the group with[MemberOf<T>](see Enabling a single endpoint). - Existing methods can become hooks. An endpoint class that already declares
public static bool IsEnabled(IServiceProvider)orpublic static string? GetPath(IServiceProvider)now implicitly implements the newIEndpointBasehook of that name, and the mapping calls it. Rename it if that wasn't intended. EndpointDescriptorhas a fifth positional member,IsPathConfigurable(defaulting tofalse). Code that constructs a descriptor still compiles, but positional deconstruction (var (name, path, methods, type) = descriptor) and positional patterns must add it.
What's new
- An endpoint can switch itself off with
IEndpointBase.IsEnabled(IServiceProvider), as a group already could. - An endpoint can take its route from configuration with
IEndpointBase.GetPath(IServiceProvider). - Both
Configurehooks receive the application's rootIServiceProvider. - Every hook is evaluated once, when the routes are mapped: endpoints are fixed at startup, and a configuration change at run time takes a restart.
Quick start
An endpoint is a class with a static Path and a handler:
using MintPlayer.AspNetCore.Endpoints;
public class HealthCheck : IGetEndpoint
{
public static string Path => "/health";
public Task<IResult> HandleAsync(HttpContext httpContext)
=> Task.FromResult(Results.Ok(new { status = "healthy" }));
}
The generator writes one Map{AssemblyName}Endpoints() extension that maps every endpoint in the
assembly. For assembly MyShop.Api:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapMyShopApiEndpoints();
app.Run();
[assembly: EndpointsMethodName("MapEndpoints")] renames it. The method lives in
MintPlayer.AspNetCore.Endpoints.Generated, which the generated file imports with a global using.
The interface ladder
Pick the rung that matches what the handler needs. Anything with a type argument must be partial:
the generator adds the base class that binds the request (MPEP001 otherwise, with a one-click fix).
| Interface | You write | Request body |
|---|---|---|
IGetEndpoint, IPostEndpoint, IPutEndpoint, IPatchEndpoint, IDeleteEndpoint, IEndpoint |
Task<IResult> HandleAsync(HttpContext) |
read it yourself |
IGetEndpoint<TResponse>, IDeleteEndpoint<TResponse> |
override Task<IResult> HandleAsync(CancellationToken) |
none |
IPostEndpoint<TRequest>, IPutEndpoint<TRequest>, IPatchEndpoint<TRequest> |
override Task<IResult> HandleAsync(TRequest, CancellationToken) |
TRequest, content-negotiated |
I…Endpoint<TRequest, TResponse> (every verb) |
the same | TRequest, and TResponse is documented as the success response |
IEndpoint<TRequest> / IEndpoint<TRequest, TResponse> (custom Methods) |
the same, plus override BindRequestAsync |
whatever you bind |
Watch the one-argument rung. IGetEndpoint<T> and IDeleteEndpoint<T> take the response
type — GET and DELETE have no body. IPostEndpoint<T>, IPutEndpoint<T> and IPatchEndpoint<T> take
the request. With two arguments it is always request, then response. (MPEP018 hints when the single
argument of a GET/DELETE is named like a request.) A GET or DELETE that does declare a request reads it
from the body like a POST.
SuccessStatusCode (default 200) sets the documented success status:
static int IEndpoint<TRequest, TResponse>.SuccessStatusCode => 201;, or
static int IResponseEndpoint<TResponse>.SuccessStatusCode => … on the response-only rung.
A sample API
The rest of this README uses these groups, models and endpoints:
using MintPlayer.AspNetCore.Endpoints;
public class ApiGroup : IEndpointGroup
{
public static string Prefix => "/api";
}
[MemberOf<ApiGroup>]
public class UsersApi : IEndpointGroup
{
public static string Prefix => "/users";
static void IEndpointGroup.Configure(RouteGroupBuilder group, IServiceProvider services) => group.WithTags("Users");
}
public record UserResponse(int Id, string Name, string Email);
public record CreateUserRequest(string Name, string Email);
public record RenameUserBody(string Name);
public interface IUserStore
{
UserResponse? Find(int id);
IReadOnlyList<UserResponse> List(int page, string? search);
UserResponse Add(string name, string email);
bool Rename(int id, string name);
bool Remove(int id);
}
using MintPlayer.AspNetCore.Endpoints;
// GET /api/users?page=2&search=al — raw rung, query values bound onto the endpoint
[MemberOf<UsersApi>]
public partial class ListUsers(IUserStore users) : IGetEndpoint
{
public static string Path => "/";
[QueryParam] public int Page { get; set; } = 1;
[QueryParam] public string? Search { get; set; }
public Task<IResult> HandleAsync(HttpContext httpContext)
=> Task.FromResult(Results.Ok(users.List(Page, Search)));
}
// GET /api/users/{id} — one type argument on GET is the RESPONSE
[MemberOf<UsersApi>]
public partial class GetUser(IUserStore users) : IGetEndpoint<UserResponse>
{
public static string Path => "/{id}";
[RouteParam] public int Id { get; set; }
public override Task<IResult> HandleAsync(CancellationToken ct)
=> Task.FromResult(users.Find(Id) is { } user ? Results.Ok(user) : Results.NotFound());
}
// POST /api/users — request and response
[MemberOf<UsersApi>]
public partial class CreateUser(IUserStore users) : IPostEndpoint<CreateUserRequest, UserResponse>
{
public static string Path => "/";
static int IEndpoint<CreateUserRequest, UserResponse>.SuccessStatusCode => 201;
public override Task<IResult> HandleAsync(CreateUserRequest request, CancellationToken ct)
{
var user = users.Add(request.Name, request.Email);
return Task.FromResult(Results.Created(Routes.Api.Users.GetUser(id: user.Id), user));
}
}
// PUT /api/users/{id} — the id from the route, the rest from the body
[MemberOf<UsersApi>]
public partial class RenameUser(IUserStore users) : IPutEndpoint<RenameUserBody>
{
public static string Path => "/{id}";
[RouteParam] public int Id { get; set; }
public override Task<IResult> HandleAsync(RenameUserBody request, CancellationToken ct)
=> Task.FromResult(users.Rename(Id, request.Name) ? Results.NoContent() : Results.NotFound());
}
// DELETE /api/users/{id}
[MemberOf<UsersApi>]
public partial class DeleteUser(IUserStore users) : IDeleteEndpoint
{
public static string Path => "/{id}";
[RouteParam] public int Id { get; set; }
public Task<IResult> HandleAsync(HttpContext httpContext)
=> Task.FromResult(users.Remove(Id) ? Results.NoContent() : Results.NotFound());
}
Custom verbs
A class may declare Methods itself; the class member is more specific than the interface's, so it
wins (a class implementing two verb interfaces must do this, or the compiler reports CS8705). For
anything else implement IEndpoint:
using MintPlayer.AspNetCore.Endpoints;
public class Preflight : IEndpoint
{
public static string Path => "/api/{**path}";
public static IEnumerable<string> Methods => ["OPTIONS", "HEAD"];
public Task<IResult> HandleAsync(HttpContext httpContext) => Task.FromResult(Results.Ok());
}
HttpVerbs.Get, HttpVerbs.Post, … hold the standard lists.
Route and query values
Only properties marked [RouteParam] or [QueryParam] are bound from the URL, and the body binds to
TRequest and nothing else.
- The endpoint must be
partial(MPEP014; MPEP001 on a typed endpoint), because the binder is generated into it, and the property needs asetaccessor (MPEP020). - The key is the property name, case-insensitive;
[RouteParam("userId")]/[QueryParam("q")]names it explicitly. A[RouteParam]whose key is not in the composed route is MPEP009; a{token}on a typed endpoint that nothing binds is MPEP008. - Supported types:
string, any enum (case-insensitive, must be a defined value), and any type implementingIParsable<T>(int,Guid,DateOnly,decimal, …), parsed with the invariant culture. Anything else is MPEP013. There is no reflective fallback. - A nullable property is optional (absent →
null); a property with an initializer keeps it when the value is absent; any other property is required. - Failures answer
400before the handler runs, asapplication/problem+json:The route parameter 'id' must be a valid Int32; 'abc' is not.,The query parameter 'kind' must be one of: Book, Vinyl; 'x' is not.,The query parameter 'page' is required.A typed endpoint routes them throughOnBindFailedAsync(below); a raw endpoint has no base class, so its failures are not customisable.
Why explicit, not by convention. Binding happens on the endpoint class, and every settable
property of it would otherwise be writable from the URL — including state you set from DI. Convention
would make ?anyProperty=… an over-posting hole by default. A route convention would also bind or not
depending on whether the generator can read Path (it cannot for a computed one, or across
assemblies). One attribute per value is the price.
URL fragments (#section) cannot be bound: browsers never send them to the server (RFC 3986 §3.5).
The request body
POST, PUT and PATCH endpoints (and GET/DELETE with a request type) read TRequest from the body:
- With
AddControllers()/AddMvc(), through MVC's input formatters — JSON, XML, your own. - Otherwise with
ReadFromJsonAsync<TRequest>().
A request that cannot be bound never reaches HandleAsync:
| Request | Response |
|---|---|
| Empty, whitespace-only or malformed JSON | 400 |
| A content type no formatter reads | 415 |
A literal JSON null, or a binder returning default |
400 |
| An input formatter that recorded model errors | 400, errors in the detail |
Override BindRequestAsync to bind differently, and throw EndpointBindingException to reject the
request. Override OnBindFailedAsync to change the response for every bind failure, route and query
ones included (failure is null when binding produced no request; on the response-only rung it is
never null):
using MintPlayer.AspNetCore.Endpoints;
public record AvatarUpload(IFormFile File, string? Caption);
public partial class UploadAvatar : IPostEndpoint<AvatarUpload>
{
public static string Path => "/avatars/{userId}";
[RouteParam] public int UserId { get; set; }
protected override async ValueTask<AvatarUpload?> BindRequestAsync(HttpContext context)
{
var form = await context.Request.ReadFormAsync(context.RequestAborted);
if (form.Files.GetFile("file") is not { } file)
throw new EndpointBindingException(StatusCodes.Status400BadRequest, "A 'file' part is required.");
return new AvatarUpload(file, form["caption"]);
}
protected override ValueTask<IResult> OnBindFailedAsync(HttpContext context, EndpointBindingException? failure)
=> new(Results.Problem(
statusCode: failure?.StatusCode ?? StatusCodes.Status400BadRequest,
title: "The upload could not be read.",
detail: failure?.Message));
public override Task<IResult> HandleAsync(AvatarUpload request, CancellationToken ct)
=> Task.FromResult(Results.NoContent());
}
Any other exception from a binder propagates: the library cannot tell a malformed request from a bug.
Groups
A group is a class implementing IEndpointGroup with a Prefix and an optional Configure. An
endpoint joins it with [MemberOf<TGroup>]; a group joins a parent group the same way, to any depth.
Path is relative to the composed prefix (/api + /users + /{id}); repeating the prefix in Path
is MPEP010. [MemberOf<T>] allows one group per class, which the compiler enforces (CS0579); a cycle
is MPEP005, and a group no endpoint joins is not mapped (MPEP016).
Membership inherits: [MemberOf<T>] on a base class applies to every endpoint deriving from it, and
the nearest declaration wins, so a derived class can move to another group:
using MintPlayer.AspNetCore.Endpoints;
public class AdminApi : IEndpointGroup
{
public static string Prefix => "/admin";
}
[MemberOf<AdminApi>]
public abstract class AdminEndpoint;
// GET /admin/audit — joins AdminApi through its base class
public class AuditLog : AdminEndpoint, IGetEndpoint
{
public static string Path => "/audit";
public Task<IResult> HandleAsync(HttpContext httpContext) => Task.FromResult(Results.Ok());
}
// GET /api/status — its own [MemberOf] is nearer than the base class's
[MemberOf<ApiGroup>]
public class ServerStatus : AdminEndpoint, IGetEndpoint
{
public static string Path => "/status";
public Task<IResult> HandleAsync(HttpContext httpContext) => Task.FromResult(Results.Ok());
}
A base class of your own blocks the generated one (MPEP002 on a typed endpoint) — unless it already
derives from a library base (PostEndpoint<T>, GetEndpoint<T>, ResponseEndpoint, …), which is
the supported way to share typed endpoint behaviour; such a class need not be partial.
A group can switch itself off. IEndpointGroup.IsEnabled(IServiceProvider) defaults to true; it is
evaluated once, when the routes are mapped, and a group that returns false maps none of its endpoints
and none of its nested groups — in the generated mapping and in MapEndpoint<T>() alike. Use it for an
optional feature that the application turns on through its options:
using Microsoft.Extensions.Options;
using MintPlayer.AspNetCore.Endpoints;
public class ReportOptions
{
public bool Enabled { get; set; }
}
// Mapped only when the application sets ReportOptions.Enabled.
public class ReportsApi : IEndpointGroup
{
public static string Prefix => "/reports";
static bool IEndpointGroup.IsEnabled(IServiceProvider services)
=> services.GetRequiredService<IOptions<ReportOptions>>().Value.Enabled;
}
The condition switches the group as a whole; a condition that applies to one endpoint belongs on that
endpoint (see below). The static Endpoints descriptor list still lists every declared endpoint, enabled
or not — whether its group or the endpoint itself is switched off. To ask what is actually mapped, see
Is an endpoint mapped?.
Endpoints are fixed at startup. IsEnabled (on a group or an endpoint) and GetPath are evaluated
once, when the routes are mapped. A configuration change at run time maps or unmaps nothing; it takes a
restart.
IsEnabled decides whether a group exists, not what it carries. Returning false removes every
route of the group. To make a convention depend on an option — CORS only when an origin is
configured, say — read the option in Configure, which receives the same root provider:
using Microsoft.Extensions.Options;
using MintPlayer.AspNetCore.Endpoints;
public class TokenOptions
{
public string? CorsPolicy { get; set; }
}
// POST /connect/token always exists; it only carries CORS when a policy is configured.
public class ConnectApi : IEndpointGroup
{
public static string Prefix => "/connect";
static void IEndpointGroup.Configure(RouteGroupBuilder group, IServiceProvider services)
{
if (services.GetRequiredService<IOptions<TokenOptions>>().Value.CorsPolicy is { } policy)
group.RequireCors(policy);
}
}
Enabling a single endpoint
IEndpointBase.IsEnabled(IServiceProvider) is the same switch for one endpoint, so no if is needed
around the mapping call. It is checked after the group chain; when it returns false the endpoint is not
mapped, and neither its GetPath nor its Configure is called. The full order, once per endpoint at
startup, is: group chain → endpoint IsEnabled → GetPath → parameter check → map → Configure.
A disabled endpoint makes MapEndpoint<T>() return without mapping anything, as a disabled group does. A group that carries a shared convention once — here
RequireAuthorization — can hold endpoints that each decide for themselves whether they exist:
using Microsoft.Extensions.Options;
using MintPlayer.AspNetCore.Endpoints;
public class AccountOptions
{
public bool LocalPasswords { get; set; }
}
public class ManageApi : IEndpointGroup
{
public static string Prefix => "/manage";
static void IEndpointGroup.Configure(RouteGroupBuilder group, IServiceProvider services)
=> group.RequireAuthorization();
}
// POST /manage/password — only where accounts have local passwords.
[MemberOf<ManageApi>]
public class ChangePassword : IPostEndpoint
{
public static string Path => "/password";
static bool IEndpointBase.IsEnabled(IServiceProvider services)
=> services.GetRequiredService<IOptions<AccountOptions>>().Value.LocalPasswords;
public Task<IResult> HandleAsync(HttpContext httpContext) => Task.FromResult(Results.NoContent());
}
// GET /manage/profile — always.
[MemberOf<ManageApi>]
public class ManageProfile : IGetEndpoint
{
public static string Path => "/profile";
public Task<IResult> HandleAsync(HttpContext httpContext) => Task.FromResult(Results.Ok());
}
A class is a group or an endpoint, not both. Groups describe organisation and endpoints describe
handlers: one class implementing both would have one IsEnabled answer for both roles, and one
Configure name cover two builders. The generator reports such a class as MPEP036 (Error) and treats
it as a group only; move the endpoint into a class of its own and join it with [MemberOf<TGroup>].
Middleware for a group's prefix
A group has no middleware hook of its own: middleware order is decided once, for the whole
application, and a group is a unit of routing, not of the request pipeline. To run middleware only for
a group's routes, branch the pipeline on its Prefix — still a compile-time reference to the group, so
renaming the prefix moves the middleware with the routes:
using MintPlayer.AspNetCore.Endpoints;
public static class AdminAuditing
{
// app.UseAdminAuditing() in Program.cs, before the endpoints are mapped.
public static IApplicationBuilder UseAdminAuditing(this IApplicationBuilder app)
=> app.UseWhen(
context => context.Request.Path.StartsWithSegments(AdminApi.Prefix),
admin => admin.Use(async (context, next) =>
{
context.Response.Headers["X-Audited"] = "admin";
await next(context);
}));
// A nested group's Prefix is relative to its parent's, so compose the chain yourself.
public static IApplicationBuilder UseUsersAuditing(this IApplicationBuilder app)
=> app.UseWhen(
context => context.Request.Path.StartsWithSegments(ApiGroup.Prefix + UsersApi.Prefix),
users => users.Use(async (context, next) =>
{
context.Response.Headers["X-Audited"] = "users";
await next(context);
}));
}
The caveat is the nested case. UsersApi.Prefix is only /users; the route a request spells is
/api/users, because UsersApi sits in ApiGroup. If the group is later moved with [MemberOf<T>],
its routes follow automatically but a hand-composed ApiGroup.Prefix + UsersApi.Prefix does not. A
root group, like AdminApi, is the safe case: its Prefix is the whole prefix.
A route from configuration: GetPath
Path is static and sees nothing. When the route has to come from options or configuration — a
WebSocket a library exposes at a path its host chooses, say — override
IEndpointBase.GetPath(IServiceProvider). It is evaluated once, when the routes are mapped, and the
endpoint is mapped at GetPath(services) ?? Path, in the generated mapping and in MapEndpoint<T>()
alike. Path stays required: it is the default, and the route that ships when nothing is configured.
using System.Net.WebSockets;
using Microsoft.Extensions.Options;
using MintPlayer.AspNetCore.Endpoints;
public class TunnelOptions
{
// Defaults to the endpoint's own Path, so the route is written once.
public string Path { get; set; } = DevTunnel.DefaultPath;
}
public class DevTunnel : IGetEndpoint
{
public const string DefaultPath = "/dev/tunnel";
public static string Path => DefaultPath;
public static string? GetPath(IServiceProvider services)
=> services.GetRequiredService<IOptions<TunnelOptions>>().Value.Path;
public async Task<IResult> HandleAsync(HttpContext httpContext)
{
if (!httpContext.WebSockets.IsWebSocketRequest)
return Results.BadRequest();
using var socket = await httpContext.WebSockets.AcceptWebSocketAsync();
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", httpContext.RequestAborted);
return Results.Empty;
}
}
- Null means
Path, whetherGetPathis not overridden or an override returns null. It never means "not mapped" — to switch an endpoint off, useIsEnabled. - Only the root provider is available, as for every hook: no request exists yet, so read options and configuration, never a scoped service.
- Endpoints are fixed at startup.
GetPath(likeIsEnabled) is evaluated once, when the routes are mapped; a configuration change at run time does not move the route, it takes a restart. - A configured path must keep the route parameters of
Path, by name: bound properties and the build-time route checks (MPEP007–MPEP010) usePath. Literal segments, constraints and the parameter's case may differ (/hooks/{id}→/custom/{ID:int}is fine). Mapping enforces this at startup: a configured/x/{key}against a default/hooks/{id}throws anInvalidOperationExceptionnaming the endpoint and both patterns. - What is left out. The generator cannot know the configured route, so the endpoint gets no typed
link and no client contract, its OpenAPI route parameters come only from its bound properties, and
its entry in the static
Endpointslist reports the defaultPathwithIsPathConfigurableset. MPEP034 (Info) says so at build time, in place of MPEP011. Two such endpoints sharing a defaultPathstill get MPEP007, because the default routes really do collide.
The override is detected however it is written: implicitly, explicitly
(static string? IEndpointBase.GetPath(…)), on a base class, or as a default on an interface of your own
that extends an endpoint interface. A GetPath on a derived class that does not list the endpoint
interface again is not called — the inherited implementation stays in force — and is reported as MPEP032.
Generic endpoints
In short. A library can declare an endpoint that is generic over a type only the application
knows — typically its user type: ListPasskeys<TUser>. The library cannot map it, because it cannot
choose TUser. The application chooses it — this is called closing the endpoint — with one
assembly-level attribute, and the application's own generated Map…Endpoints() then maps
ListPasskeys<AppUser> like any other endpoint.
The library declares the endpoint as usual, with a type parameter:
using MintPlayer.AspNetCore.Endpoints;
namespace MyAuth;
public class AuthUser
{
public string DisplayName { get; set; } = "";
}
public class AuthApi : IEndpointGroup
{
public static string Prefix => "/auth";
}
[MemberOf<AuthApi>]
public partial class ListPasskeys<TUser> : IGetEndpoint<string[]> where TUser : AuthUser, new()
{
public static string Path => "/passkeys/{userId}";
[RouteParam] public int UserId { get; set; }
public override Task<IResult> HandleAsync(CancellationToken ct)
=> Task.FromResult(Results.Ok(new[] { $"{new TUser().DisplayName} #{UserId}" }));
}
The application adds one line. It can go in any file of the application project; the top of
Program.cs is the usual place:
using MintPlayer.AspNetCore.Endpoints;
// Every endpoint whose type parameter is constrained to MyAuth.AuthUser gets AppUser.
[assembly: EndpointTypeArgument<MyAuth.AuthUser, AppUser>]
public class AppUser : MyAuth.AuthUser;
Nothing else changes. The application's existing app.MapMyShopApiEndpoints() call now also maps
GET /auth/passkeys/{userId} as ListPasskeys_AppUser, with everything a hand-written endpoint gets:
route binding, the group chain, a typed link (Routes.Auth.ListPasskeys_AppUser(userId: 1)), the
contract, OpenAPI path parameters, and the duplicate route and name checks (MPEP007, MPEP012).
Two ways to write it
| Form | Use it when | What it closes |
|---|---|---|
[assembly: EndpointTypeArgument<TConstraint, TArgument>] |
the type parameter has a constraint type, such as where TUser : AuthUser |
every endpoint with a type parameter constrained to exactly TConstraint — one line for a whole library |
[assembly: EndpointTypeArgument(typeof(Echo<>), typeof(string))] |
there is no constraint type to key on (where T : class), or the constraint uses another type parameter |
that one endpoint; type arguments in declaration order |
The first form declares where TArgument : TConstraint, so the compiler rejects an AppUser that is
not an AuthUser (CS0311). The explicit form wins over constraint keys for its endpoint, and each
occurrence is one closing, so one endpoint can be closed more than once with different arguments.
The rules
- The key must match exactly. A type parameter is bound when one of its constraint types is
TConstraint— not a base class or interface of it. If the library sayswhere TUser : AuthUser, thenEndpointTypeArgument<object, AppUser>binds nothing and is reported as closing nothing (MPEP030, a warning on the attribute). - Every type parameter must be bound, including those of a type the endpoint is nested in; otherwise the endpoint is not mapped (MPEP031).
- Every constraint is checked —
new(),class,struct,unmanaged,notnulland further constraint types. A violation is an error on the attribute (MPEP026), never code that does not compile. Two attributes binding one parameter are MPEP027; a wrong number of explicit type arguments is MPEP028. - Names are
{Name}_{TypeArguments}, using the CLR name of each type argument, outermost containing type's first:ListPasskeys_AppUser,Echo_String,Echo_List_Int32,Echo_Int32Array.MapEndpoint<T>()uses the same rule, so two closings of one endpoint never collide. - Where it works. Generic endpoints in the application's own project close the same way. A
library's must be
public, as must every group on their chain (MPEP029), and the library must be built with this generator 11.2 or later. References are read only when the application declares anEndpointTypeArgument, so an application that doesn't use this pays nothing. - Conditional features stay in the library. The generated mapping honours each group's
IsEnabled(see Groups), so a library can put an optional cluster of generic endpoints in a group that turns itself off. An endpoint's ownIsEnabledis honoured too (see Enabling a single endpoint), so a single optional endpoint needs no group of its own.
What the library's own build does. Its generator leaves the generic endpoint out of the library's
Map…Endpoints(), links and contract, and says so (MPEP025, Info). It still emits the endpoint's
partial with the type parameters repeated, so [RouteParam] binding works once the endpoint is closed,
and it records the endpoint's route and groups in the library assembly, which is where the application's
generator reads them.
Without the attribute. An application that knows its closed type can derive one:
public class EchoString : Echo<string> { } is an ordinary endpoint, and [MemberOf<T>] is inherited.
app.MapEndpoint<ListPasskeys<AppUser>>() maps a closing by hand, named the same way, but without
OpenAPI path parameters (see Manual registration).
Generic constraint types
A closed generic constraint is an ordinary key: where TUser : IMember<Guid> is bound by
EndpointTypeArgument<IMember<Guid>, Member>. A constraint that uses another type parameter —
where TUser : IMember<TKey> — matches no key, and TKey is not inferred from one. A key on the same
generic type is reported instead (MPEP033), and nothing is mapped. Close such an endpoint with the
explicit form; every constraint is checked after substitution (MPEP026 when Member is not an
IMember<Guid>):
using MintPlayer.AspNetCore.Endpoints;
[assembly: EndpointTypeArgument<MyIdentity.IMember<Guid>, Member>] // Profile<TUser>
[assembly: EndpointTypeArgument(typeof(MyIdentity.Keys<,>), typeof(Member), typeof(Guid))] // Keys<TUser, TKey>
namespace MyIdentity
{
public interface IMember<TKey>
{
TKey Id { get; }
}
public class Profile<TUser> : IGetEndpoint where TUser : IMember<Guid>
{
public static string Path => "/identity/profile";
public Task<IResult> HandleAsync(HttpContext httpContext) => Task.FromResult(Results.Ok(typeof(TUser).Name));
}
public partial class Keys<TUser, TKey> : IGetEndpoint<string> where TUser : IMember<TKey>
{
public static string Path => "/identity/keys/{index}";
[RouteParam] public int Index { get; set; }
public override Task<IResult> HandleAsync(CancellationToken ct)
=> Task.FromResult(Results.Ok($"{typeof(TKey).Name} key #{Index}"));
}
}
public class Member : MyIdentity.IMember<Guid>
{
public Guid Id { get; } = Guid.NewGuid();
}
The closings are Profile_Member and Keys_Member_Guid. The explicit form wins for
Keys, so the key raises no MPEP033 there.
Dependency injection
An endpoint is created for every request from HttpContext.RequestServices, by a factory
ActivatorUtilities compiles once. So constructor injection — primary constructors included, as in the
sample API — gets scoped services from the request's scope, exactly like a controller: register
builder.Services.AddScoped<IUserStore, UserStore>() and each request gets its own IUserStore, shared
with everything else in that request. IDisposable and IAsyncDisposable endpoints are disposed when the
handler returns (IAsyncDisposable preferred; the base classes' DisposeAsync forwards to Dispose()).
Because the endpoint is per request, bound properties never leak between requests.
Attributes, Configure and endpoint metadata
Every attribute on the endpoint class, inherited ones included, becomes endpoint metadata, so the framework's own attributes work
as they do on a controller action — verified end to end for [Authorize], [Authorize(Policy = …)],
[EnableRateLimiting], [Tags] and [ProducesResponseType]. For anything imperative, implement
Configure:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using MintPlayer.AspNetCore.Endpoints;
[Authorize(Policy = "Reports")]
[EnableRateLimiting("reports")]
[Tags("Reports")]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public partial class DownloadReport : IGetEndpoint
{
public static string Path => "/reports/{name}";
[RouteParam] public string Name { get; set; } = "";
static void IEndpointBase.Configure(RouteHandlerBuilder builder, IServiceProvider services) => builder.RequireCors("reports");
public Task<IResult> HandleAsync(HttpContext httpContext)
=> Task.FromResult(Results.Text($"report {Name}", "text/csv"));
}
A group's Configure receives its RouteGroupBuilder, as UsersApi above shows. Both hooks also
receive the application's root IServiceProvider, so a convention can depend on options or
configuration without a cast (see Groups for the CORS example). Each hook is called once,
when the routes are mapped; no request exists yet, so resolve singletons and options from it, never a
scoped service.
Upgrading a one-argument Configure from 11.3 or earlier: see Upgrading to 11.4.
Besides the class's attributes, every endpoint the library maps carries one EndpointTypeMetadata. Its
EndpointType is the closed endpoint class, and it is what the next section reads.
Is an endpoint mapped?
To ask whether an endpoint is served, ask about its class, not its route string. A renamed route or group prefix then cannot silently change the answer, and a renamed or deleted class fails to compile.
using MintPlayer.AspNetCore.Endpoints;
public class GetCapabilities : IGetEndpoint
{
public static string Path => "/capabilities";
public Task<IResult> HandleAsync(HttpContext httpContext)
{
var endpoints = httpContext.RequestServices.GetRequiredService<EndpointDataSource>();
return Task.FromResult(Results.Ok(new
{
passkeys = endpoints.IsEndpointMapped(typeof(MyAuth.ListPasskeys<>)),
reports = endpoints.IsEndpointMapped<DownloadReport>(),
}));
}
}
- A closed class matches by equality.
- A generic type definition matches any mapped closing of it, such as
ListPasskeys<AppUser>from Generic endpoints. That includes a closing the application made with[assembly: EndpointTypeArgument], and a non-generic class that derives from a closing. Base classes count; interfaces never do. So a query for one of the library's own generic bases, such asPostEndpoint<,>, matches every endpoint derived from it. - An endpoint that was not mapped answers
false, whether its group's or its ownIsEnabledreturnedfalseor the mapping call sat inside anif. This is what the generatedEndpointsdescriptor list cannot tell you, because it lists every endpoint that is declared. - Endpoints mapped by anything else, such as a plain
MapGetlambda orMapIdentityApi, carry no endpoint class and never match.
Ask at request time. The EndpointDataSource in the container holds no endpoints until the host
has started. A call in Program.cs before app.Run(), or in an IHostedService.StartAsync, answers
false, with no error. The earliest safe point is IHostApplicationLifetime.ApplicationStarted. There is
no overload on IEndpointRouteBuilder, on purpose: reading its data sources during startup builds the
endpoints, and adding a convention to an already-mapped route afterwards throws.
The class is recorded by EndpointAttributes.ForMetadata, which the generated mapping calls at run
time. So a library compiled against an older version of this package gets it too, without a rebuild,
once the application references this version.
Validation
Validation runs on the body only, after binding and before the handler, with
Microsoft.Extensions.Validation. It runs when all of these hold:
- The request type carries
[ValidatableType]in hand-written code. The library cannot mark it for you: one source generator never sees another's output. A request type with DataAnnotations but no[ValidatableType]is MPEP015. - The assembly declaring that type calls
AddValidation()— exactly once. On .NET 10 a second call site in the same assembly fails the validation generator withCS8785(a warning) and registers no resolver, so every type in that assembly silently stops validating. Keep the one call in a wrapper and call the wrapper fromProgram.cs— never both. - On .NET 10, the project has
<NoWarn>$(NoWarn);ASP0029</NoWarn>: the validation API is[Experimental]there and reports as an error. .NET 11 needs nothing.
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Validation;
using MintPlayer.AspNetCore.Endpoints;
[ValidatableType]
public record RegisterUserRequest([Required] string Name, [Required, EmailAddress] string Email);
public partial class RegisterUser(IUserStore users) : IPostEndpoint<RegisterUserRequest, UserResponse>
{
public static string Path => "/register";
public override Task<IResult> HandleAsync(RegisterUserRequest request, CancellationToken ct)
=> Task.FromResult(Results.Ok(users.Add(request.Name, request.Email)));
// Optional: the default is a 400 problem+json with the errors.
protected override ValueTask<IResult> OnValidationFailedAsync(HttpContext context, IReadOnlyDictionary<string, string[]> errors)
=> new(Results.ValidationProblem(errors, title: "The registration is invalid."));
}
// This assembly's one AddValidation() call site; Program.cs calls builder.Services.AddMyShopValidation().
public static class MyShopValidation
{
public static IServiceCollection AddMyShopValidation(this IServiceCollection services)
=> services.AddValidation();
}
A request type declared in another assembly is only validated if that assembly calls
AddValidation() itself: give it the same kind of wrapper and call both wrappers from the app. (If
you cannot change it, declare a local [ValidatableType] type with a property of the external type;
the validation generator then recurses into it.)
[Range] and friends on a [RouteParam]/[QueryParam] property are not evaluated — the endpoint
is not a validatable type. The binder enforces the type; put business rules in HandleAsync. (They do
reach the OpenAPI schema as minimum/maximum/maxLength, so the document advertises a constraint
nothing checks.) Error keys are member paths (Name, Address.Street); an IValidatableObject result
naming no member lands under "".
Endpoint names and typed links
Every endpoint is mapped with .WithName(name): its class name, or [EndpointDescriptorName("…")].
That one name is the route name, the OpenAPI operationId and the typed-link method name, so it must
be unique in the assembly — a duplicate is MPEP012 at build time rather than the
Duplicate endpoint name exception ASP.NET Core throws on the first request. Name through the
attribute: a WithName inside Configure is overridden.
The generated internal static class Routes mirrors the groups (one trailing Api or Group is
dropped: ApiGroup → Api, UsersApi → Users) with one method per endpoint. Its parameters are the
route tokens — typed from their [RouteParam] property, string otherwise, optional for {id?},
{n=1} and catch-alls — then one optional parameter per [QueryParam]. Each also has a
{Name}Template constant. It returns an EndpointRoute:
using MintPlayer.AspNetCore.Endpoints;
public partial class UserLinks : IGetEndpoint
{
public static string Path => "/links/{id}";
[RouteParam] public int Id { get; set; }
public Task<IResult> HandleAsync(HttpContext httpContext)
{
EndpointRoute link = Routes.Api.Users.GetUser(id: Id);
return Task.FromResult(Results.Ok(new
{
path = link.ToString(), // "/api/users/42" — also the implicit string conversion
routed = link.Path(httpContext), // through LinkGenerator, with the request's PathBase
absolute = link.Uri(httpContext), // scheme, host and PathBase
template = Routes.Api.Users.GetUserTemplate,
}));
}
}
ToString() (and the implicit string conversion Results.Created uses) needs no LinkGenerator: it
reproduces routing's own substitution and UrlEncoder.Default, matching LinkGenerator byte for byte
with default options. It cannot see route constraints, LowercaseUrls, LowercaseQueryStrings,
AppendTrailingSlash, parameter transformers or a replaced UrlEncoder; if you use any of those, call
Path(httpContext) or Path(linkGenerator), which ask the framework and throw where it would return
null. An endpoint whose route is not a compile-time constant, whose route is chosen at map time
(GetPath), or whose name is a duplicate, gets no link.
The descriptors are available too: MyShopApiEndpointsExtensions.Endpoints lists an
EndpointDescriptor(Name, Path, Methods, HandlerType, IsPathConfigurable) per endpoint, with the fully composed Path
(the default one, flagged by IsPathConfigurable, for an endpoint that overrides GetPath).
OpenAPI
The mapping always declares the request body (application/json, required), 400 (and 415 where
there is a body) as problem details, and Produces<TResponse>(SuccessStatusCode). A success status is
assumed when nothing declares one.
Route and query parameters need Microsoft.AspNetCore.OpenApi. When the server references it (a
version built on Microsoft.OpenApi 2.x or later, as the .NET 10 and 11 packages are), the generator also emits EndpointOpenApi.g.cs with typed
parameter schemas — int, enums with their values, [Range]/[StringLength] limits. Without the
package that file is not emitted and the runtime library takes no dependency on it. operationId is the
endpoint name; an endpoint answering several verbs gets the verb appended (PreflightHead) so the ids
stay unique.
MapEndpoint<T>() (below) documents the body and failures but not route or query parameters: it
has no compile-time parameter type to show ApiExplorer, so a templated route mapped by hand makes the
document invalid. Use the generated mapping for anything documented.
Contract snapshot in CI
Commit the OpenAPI document and let CI compare it. Microsoft.Extensions.ApiDescription.Server (same
version as Microsoft.AspNetCore.OpenApi) writes it on every build:
<PropertyGroup>
<OpenApiGenerateDocumentsOnBuild>true</OpenApiGenerateDocumentsOnBuild>
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)\openapi</OpenApiDocumentsDirectory>
<OpenApiGenerateDocumentsOptions>--openapi-version OpenApi3_1</OpenApiGenerateDocumentsOptions>
</PropertyGroup>
Pin OpenApiVersion in AddOpenApi as well — the default differs between .NET 10 (3.1) and .NET 11
(3.2). A multi-targeted project writes the document for its first target framework only. Generating it
runs your Program.cs: put migrations and seeding behind
Assembly.GetEntryAssembly()?.GetName().Name != "GetDocument.Insider", but never skip app.Run(), or
the document has no paths. In CI, fail when the build leaves the file changed (git status --porcelain)
and run oasdiff breaking base.json head.json --fail-on ERR against the base branch's copy; this
repository's pull-request.yml does both.
Typed client in another project
Every endpoint with a typed link is also described in the server assembly's metadata
([assembly: EndpointContract(…)]): name, verbs, route, parameters and their types, request and response
types. A client project — Blazor WebAssembly, a console tool, another service — generates a typed
HttpClient wrapper from it without the server's source and without ASP.NET Core:
<PropertyGroup>
<GenerateEndpointsClient>true</GenerateEndpointsClient>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MintPlayer.AspNetCore.Endpoints.Generator" Version="…" PrivateAssets="all" />
<ProjectReference Include="..\MyShop.Contracts\MyShop.Contracts.csproj" />
<EndpointsServerReference Include="..\MyShop.Api\MyShop.Api.csproj" />
</ItemGroup>
That generates internal sealed partial class {LastNameSegment}Client (MyShop.Api → ApiClient), in
EndpointClients.g.cs together with the client of every other referenced server, with
one {Name}Async method per endpoint ({Name}{Verb}Async for a multi-verb one): route values, then the
body, then optional query values, then a CancellationToken:
using var http = new HttpClient { BaseAddress = new Uri("https://localhost:5001") };
var client = new ApiClient(http);
UserResponse? alice = await client.GetUserAsync(id: 1);
UserResponse? bob = await client.CreateUserAsync(new CreateUserRequest("Bob", "bob@example.com"));
using HttpResponseMessage page = await client.ListUsersAsync(page: 2, search: "al");
- URLs are built by the same source as
EndpointRoute.ToString(), emitted into the client. - A declared response type is read as JSON after
EnsureSuccessStatusCode()(204 →default); without one you get theHttpResponseMessageto inspect and dispose. - Renaming or removing a server endpoint breaks the client's build, not its first request.
Never ProjectReference the server: it drags FrameworkReference Microsoft.AspNetCore.App into the
client, and a Blazor WebAssembly client then fails with NETSDK1082. Without the package's targets, the
fallback is a raw reference plus the property made visible to the compiler:
<ItemGroup>
<Reference Include="MyShop.Api">
<HintPath>..\MyShop.Api\bin\$(Configuration)\net10.0\MyShop.Api.dll</HintPath>
<Private>false</Private>
</Reference>
<CompilerVisibleProperty Include="GenerateEndpointsClient" />
</ItemGroup>
MPEP021 reports an endpoint left out of the client, MPEP022 a signature type declared in the server
assembly itself (compiles, but is not deployed with the client), MPEP023 a project missing
System.Net.Http.Json/System.Text.Encodings.Web. No contracts at all — or an IDE that momentarily
sees no references — is no client and no diagnostic; code using the client then fails to compile.
Manual registration
app.MapEndpoint<HealthCheck>(); maps one endpoint by reflection, under its group chain and with the
same name the generated mapping uses — for a closed generic endpoint, with its type arguments
(Echo_String), and with the same EndpointTypeMetadata, so
IsEndpointMapped finds it either way. It maps nothing when a group on the
chain, or the endpoint itself, is not enabled (IEndpointGroup.IsEnabled, IEndpointBase.IsEnabled).
Otherwise it maps at GetPath(services) ?? Path, with the same startup parameter check.
It sees one endpoint at a time, so a duplicate name surfaces on the
first request, not as MPEP012; it cannot document route or query parameters (see
OpenAPI); and it is annotated [RequiresUnreferencedCode]/[RequiresDynamicCode]. A
cyclic group nesting throws InvalidOperationException.
Trimming and native AOT
Both runtime packages build with <IsAotCompatible>true</IsAotCompatible> and no trim or AOT warnings,
as a regression gate for the library's own code. That is not an AOT claim. The generated mapping
calls the Delegate overload of MapMethods, which RequestDelegateFactory binds by reflection at
startup; the Request Delegate Generator cannot intercept it, because one source generator never sees
another's output (measured: it intercepted a hand-written MapGet and none of the generated calls). The
body is read by MVC input formatters or ReadFromJsonAsync<T>(), neither of which this library makes
trim-safe. MapEndpoint<T>() says so with its annotations. IsEndpointMapped is trim-safe: it compares
types and walks base classes, and never reflects over members.
Diagnostics
| Code | Severity | Meaning |
|---|---|---|
| MPEP001 | Error | A typed endpoint must be partial (code fix) |
| MPEP002 | Error | The endpoint's own base class does not derive from a library endpoint base, so the generated one cannot be added |
| MPEP003 | — | Retired (endpoint in two groups; now CS0579). Never reused |
| MPEP004 | — | Retired (group with two parents; now CS0579). Never reused |
| MPEP005 | Error | A group is nested inside itself through [MemberOf<T>] |
| MPEP006 | Warning | The mapping method name was adjusted to a valid identifier |
| MPEP007 | Warning | Two endpoints answer the same verb on the same composed route (an ambiguous-match 500) |
| MPEP008 | Warning | A {token} in a typed endpoint's Path is bound to no property |
| MPEP009 | Error | A [RouteParam] names a parameter the composed route does not have (every request would be a 400) |
| MPEP010 | Warning | Path repeats its group's prefix |
| MPEP011 | Info | Path is not a compile-time constant, so the route checks are skipped (MPEP034 instead when the endpoint overrides GetPath) |
| MPEP012 | Error | Two endpoints have the same endpoint name; the later one gets no name and no link |
| MPEP013 | Error | A bound property's type is not string, an enum or IParsable<T> |
| MPEP014 | Error | An endpoint with bound properties must be partial (code fix) |
| MPEP015 | Warning | A request type has validation rules but no [ValidatableType], so they never run |
| MPEP016 | Info | A group is joined by no endpoint and is not mapped |
| MPEP017 | — | Reserved |
| MPEP018 | Info | The single type argument of IGetEndpoint<T>/IDeleteEndpoint<T> (the response) is named like a request |
| MPEP019 | Error | The endpoint is nested in a type that is not partial (code fix) |
| MPEP020 | Error | A bound property has no usable set accessor |
| MPEP021 | Warning | (client) An endpoint contract cannot become a client method |
| MPEP022 | Warning | (client) A client method uses a type declared in the server assembly |
| MPEP023 | Warning | (client) GenerateEndpointsClient is set but a prerequisite assembly is missing |
| MPEP024 | Error | An endpoint or group is private, protected, private protected or file-local (or nested in such a type), so generated code cannot name it; it is not mapped |
| MPEP025 | Info | An endpoint has type parameters, so its own assembly does not map it; an application closes it with [assembly: EndpointTypeArgument<…>] (Generic endpoints) |
| MPEP026 | Error | (on the attribute) A type argument violates a constraint of the endpoint's type parameter; the endpoint is not closed |
| MPEP027 | Error | (on the attribute) Two EndpointTypeArgument attributes bind the same type parameter |
| MPEP028 | Error | (on the attribute) The explicit EndpointTypeArgument(typeof(…), …) has the wrong number of type arguments |
| MPEP029 | Warning | (on the attribute) An endpoint cannot be closed because it, a group on its chain or a type argument cannot be named by the application (a library's must be public) |
| MPEP030 | Warning | (on the attribute) An EndpointTypeArgument closes no endpoint |
| MPEP031 | Warning | (on the attribute) An endpoint has only some of its type parameters bound, so it is not closed |
| MPEP032 | Warning | A new static Path or Methods, or a public static GetPath(IServiceProvider) on the class chain that is not the interface implementation, is ignored: the endpoint interface is implemented by a base class (or, for Methods, defaulted by the verb interface; for GetPath, by the default that maps at Path), whose value the runtime uses (and the links, contract and MPEP007 say); once per hidden member |
| MPEP033 | Warning | (on the attribute) A constraint key cannot bind a type parameter whose constraint uses another type parameter (where TUser : IUser<TKey>); close the endpoint with the explicit form |
| MPEP034 | Info | An endpoint chooses its route at map time (a public static GetPath with an IServiceProvider parameter, or an explicit IEndpointBase.GetPath): its Path is only the default, so it gets no typed link and no client contract, and the route checks apply to the default only. On a closed open-generic endpoint, reported on the attribute |
| MPEP035 | Error | A group or endpoint (or a base class of it) declares the pre-11.4 one-argument hook as a public static (or explicit-interface) Configure(RouteGroupBuilder) / Configure(RouteHandlerBuilder), which is no longer called; add IServiceProvider services as the second parameter (code fix) |
| MPEP036 | Error | A class implements both IEndpointGroup and an endpoint interface (directly, through a base class or through an interface of its own); it is treated as a group only. Move the endpoint into its own class and join it with [MemberOf<T>] |
The route checks (MPEP007–MPEP010) run only where the route is a compile-time constant; anything else
is skipped, never guessed. MPEP001, MPEP014 and MPEP019 have a Make 'X' partial code fix in Visual
Studio and Rider (for MPEP019, every enclosing type that is not yet partial), with Fix All. MPEP035
has an Add 'IServiceProvider services' parameter code fix, with Fix All.
What the generator emits
Four files, fully qualified throughout (extension methods are called in their static form), with no
using directive beyond the one global using that exposes the mapping method — so they compile under
any import set and with <ImplicitUsings>disable</ImplicitUsings>: EndpointMapping.g.cs (the partial base classes and
binders, the Map…Endpoints() method, the descriptors), EndpointRoutes.g.cs (Routes),
EndpointContracts.g.cs (the client contract) and, with the OpenAPI package, EndpointOpenApi.g.cs.
An assembly with open-generic endpoints also carries [assembly: OpenEndpoint(…)] and
[assembly: OpenEndpointGroup(…)] records at the top of EndpointMapping.g.cs, which is what an
application reads to close them.
A typed client project gets two files, under the same rules: EndpointClients.g.cs (one class per
referenced server) and EndpointClientUrl.g.cs (the URL builder). The file names never depend on the
endpoints, the groups or the servers, so they stay short and stable however the project grows.
Output is ordered by fully qualified name, so it is identical across builds.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. net11.0 is compatible. |
-
net10.0
- MintPlayer.AspNetCore.Endpoints.Abstractions (>= 11.4.0-rc.0)
-
net11.0
- MintPlayer.AspNetCore.Endpoints.Abstractions (>= 11.4.0-rc.0)
NuGet packages (10)
Showing the top 5 NuGet packages that depend on MintPlayer.AspNetCore.Endpoints:
| Package | Downloads |
|---|---|
|
MintPlayer.Spark
Low-code .NET framework for building data-driven web applications with minimal boilerplate. Uses PersistentObject pattern to eliminate DTOs and repository layers. |
|
|
MintPlayer.Spark.Authorization
Optional authorization package for MintPlayer.Spark low-code framework. Provides group-based access control for PersistentObjects and Queries. |
|
|
MintPlayer.Spark.Replication
Cross-module ETL replication for MintPlayer.Spark using RavenDB ETL tasks and the durable message bus. |
|
|
MintPlayer.Spark.Webhooks.GitHub
GitHub webhook integration for MintPlayer.Spark — broadcasts typed webhook events to the Spark message bus with support for production-to-dev WebSocket forwarding. |
|
|
MintPlayer.Spark.IdentityProvider
Self-contained OIDC Identity Provider for MintPlayer.Spark. Adds Authorization Code + PKCE flow with RavenDB storage. No external OIDC framework required. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 11.4.0-rc.0 | 162 | 10/8/2026 |
| 11.3.0-rc.0 | 503 | 10/2/2026 |
| 11.2.0-rc.0 | 335 | 9/26/2026 |
| 11.1.0-rc.0 | 73 | 9/25/2026 |
| 11.0.1-rc.0 | 71 | 9/17/2026 |
| 11.0.0 | 129 | 8/27/2026 |
| 10.0.0 | 3,043 | 3/15/2026 |