MintPlayer.AspNetCore.Endpoints 11.2.0-rc.0

This is a prerelease version of MintPlayer.AspNetCore.Endpoints.
There is a newer prerelease version of this package available.
See the version list below for details.
dotnet add package MintPlayer.AspNetCore.Endpoints --version 11.2.0-rc.0
                    
NuGet\Install-Package MintPlayer.AspNetCore.Endpoints -Version 11.2.0-rc.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MintPlayer.AspNetCore.Endpoints" Version="11.2.0-rc.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MintPlayer.AspNetCore.Endpoints" Version="11.2.0-rc.0" />
                    
Directory.Packages.props
<PackageReference Include="MintPlayer.AspNetCore.Endpoints" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MintPlayer.AspNetCore.Endpoints --version 11.2.0-rc.0
                    
#r "nuget: MintPlayer.AspNetCore.Endpoints, 11.2.0-rc.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MintPlayer.AspNetCore.Endpoints@11.2.0-rc.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MintPlayer.AspNetCore.Endpoints&version=11.2.0-rc.0&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=MintPlayer.AspNetCore.Endpoints&version=11.2.0-rc.0&prerelease
                    
Install as a Cake Tool

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.

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) => 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 a set accessor (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 implementing IParsable<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 400 before the handler runs, as application/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 through OnBindFailedAsync (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:

  1. With AddControllers()/AddMvc(), through MVC's input formatters — JSON, XML, your own.
  2. 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 is per group; an endpoint that needs its own goes in a group of its own. The static Endpoints descriptor list still lists every declared endpoint, enabled or not.

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 says where TUser : AuthUser, then EndpointTypeArgument<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, notnull and 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 an EndpointTypeArgument, 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.

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) => 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.

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 with CS8785 (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 from Program.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 "".

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, or whose name is a duplicate, gets no link.

The descriptors are available too: MyShopApiEndpointsExtensions.Endpoints lists an EndpointDescriptor(Name, Path, Methods, HandlerType) per endpoint, with the fully composed Path.

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 the HttpResponseMessage to 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). It maps nothing when a group on the chain is not enabled (IEndpointGroup.IsEnabled). 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.

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
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 new static Methods is ignored: the endpoint interface is implemented by a base class (or, for Methods, defaulted by the verb interface), 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

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.

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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (8)

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.IdentityProvider

Self-contained OIDC Identity Provider for MintPlayer.Spark. Adds Authorization Code + PKCE flow with RavenDB storage. No external OIDC framework required.

MintPlayer.Spark.MailManager

Outgoing mail for MintPlayer.Spark, as building blocks configured through IConfiguration: MJML + Scriban templates with a per-recipient culture fallback chain, queued through Spark Messaging lanes (mail-transactional, mail-bulk), SMTP (MailKit) / pickup-folder / custom transports, payload protection, VERP bounces with a pluggable DSN parser, a suppression list, one-click List-Unsubscribe and campaign fan-out.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
11.3.0-rc.0 337 10/2/2026
11.2.0-rc.0 328 9/26/2026
11.1.0-rc.0 69 9/25/2026
11.0.1-rc.0 66 9/17/2026
11.0.0 126 8/27/2026
10.0.0 3,040 3/15/2026