Typed Endpoints
Tier 3 is attributes on plain classes. The source generator turns them into route registrations and
parameter binders at compile time explicit constructor calls, explicit TryParse, explicit JSON
metadata. Every decision a reflection-based framework would make at startup is made during the build
instead, which is what lets the whole thing survive PublishAot, and means a route that cannot bind
fails your build rather than your deployment.
dotnet add package Shiny.Net.HttpServerThere is nothing else to add. The generator ships inside the core package under analyzers/, next to
the [Route]/[Get]/[Post] attributes it reads the attributes and the thing that acts on them
are one feature, and a second package to reference would only be a way to end up with attributes that
silently do nothing. It runs inside the compiler and never lands in your output.
An endpoint class
Section titled An endpoint class[Route("/api/widgets")]public class WidgetEndpoints(IWidgetStore store, ILogger<WidgetEndpoints> logger){ /// <summary>Fetches a single widget by id.</summary> [Get("/{id:int}")] public async Task<IActionResult> GetWidget(int id, CancellationToken cancellationToken) => await store.FindAsync(id, cancellationToken) is { } widget ? new OkObjectResult(widget) : new NotFoundResult();
/// <summary>Lists widgets, optionally filtered by name.</summary> [Get] public async Task<IReadOnlyList<Widget>> ListWidgets( int take = 10, string? search = null, CancellationToken cancellationToken = default ) => await store.ListAsync(take, search, cancellationToken);
/// <summary>Creates a widget.</summary> [Post] public async Task<IActionResult> CreateWidget(CreateWidget request, CancellationToken ct) { var created = await store.AddAsync(request.Name, ct); return new CreatedResult($"/api/widgets/{created.Id}", created); }}The class does not need to be partial and does not derive from anything. Constructor parameters are
resolved from the request scope, so a Scoped dependency is the same instance the rest of the request
sees. Every method is an ordinary method callable from a unit test with no HTTP involved at all.
[Route] takes an optional prefix that is prepended to every method template on the class.
[Get]/[Post]/[Put]/[Delete]/[Patch] take a template relative to it; empty means the prefix
itself. [HttpMethod("REPORT", "/x")] covers any other verb, and [NonEndpoint] excludes a public
method from discovery.
Registering them
Section titled Registering themThe generator emits one extension method per class and one for the assembly:
app.MapWidgetEndpoints(); // just this classapp.MapMyAppEndpoints(); // every [Route] class in the assemblyThe assembly-wide name comes from the assembly name Sample.Api produces
MapSampleApiEndpoints().
Parameter binding
Section titled Parameter bindingBinding is by convention, with attributes as the escape hatch. For each parameter, in order:
HttpContext,HttpRequest,HttpResponseandCancellationTokenare handed over directly.- A name matching a route token, where the type can be parsed from a string, binds from the route.
- Anything else a string can become
IParsable<T>, an enum, a nullable of either, or an array of them binds from the query string. - A complex type on a verb that carries a body binds from the JSON body (at most one per method).
- Everything else is resolved from the container.
The attributes override that: [FromRoute], [FromQuery], [FromHeader], [FromBody],
[FromServices]. Each of the first three takes an optional Name when the wire name differs from
the parameter name.
[Get("/search/{*query}")]public string Search( string query, // route (catch-all token) [FromHeader(Name = "User-Agent")] string? userAgent, // header, renamed int take = 20 // query, optional by its default) => ;A parameter with a default value is optional; a nullable one binds to null when absent. Absent
and present but unparseable are different answers, and only the second is a 400.
Bind failures
Section titled Bind failuresA parameter that cannot be bound produces a 400 before the method is ever called, with a message naming the parameter and the type it could not become:
The query parameter 'take' is missing or is not a valid int.The request body could not be read as CreateWidget.Malformed JSON is the clients mistake, so it is a 400 rather than an exception that would become a 500 about a server fault that did not happen.
Return types
Section titled Return types| Return | Response |
|---|---|
void, Task, ValueTask |
Nothing the handler wrote the response itself |
IResult / IActionResult |
Executed |
string |
text/plain |
| Any other type | JSON, from compile-time metadata |
All of them may be wrapped in Task<T> or ValueTask<T>. Anything else is SWS003 at build time.
Returning the value directly is the shortest thing that works and stays AOT-safe, because the generator registers the types metadata for you:
[Get("/{id:int}")]public async Task<Widget?> GetWidget(int id) => await store.FindAsync(id);Returning IActionResult is what you want when the status code varies. Both styles mix freely see
Results & JSON.
JSON metadata
Section titled JSON metadataDeclare one JsonSerializerContext listing everything that crosses an endpoint boundary:
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)][JsonSerializable(typeof(Widget))][JsonSerializable(typeof(IReadOnlyList<Widget>))][JsonSerializable(typeof(CreateWidget))]public partial class AppJson : JsonSerializerContext;The generator emits a module initializer that registers it with
JsonTypeInfoRegistry, so new OkObjectResult(widget) serializes from
compile-time metadata, and warns (SWS006) about any endpoint type the context does not cover.
Source generators never see each others output, so a context emitted by this generator would be
invisible to System.Text.Jsons generator and produce nothing. Declaring it in your app is the only
arrangement that works and the SWS006 warning turns the thing you would otherwise forget into a
build message.
One endpoint per class
Section titled One endpoint per classWhere a [Route] class groups endpoints, IHttpEndpoint is the opposite: the verb and route go on
the class, and the class has exactly one handler so the file you open to change an endpoint
contains that endpoint and nothing else.
[Get("/health/{component}")]public class HealthEndpoint(IHealthChecks checks) : IHttpEndpoint{ public async Task<IActionResult> HandleAsync(string component, CancellationToken ct) => await checks.RunAsync(component, ct) ? new OkResult() : new StatusCodeResult(503);}The handler is the single public method named Handle or HandleAsync. Everything above binding,
DI, return conventions, [Authorize], [Produces] applies unchanged, and the same
MapMyAppEndpoints() call picks these up alongside the controllers.
Policies as attributes
Section titled Policies as attributesEverything a raw route expresses with Require is an attribute here, emitted as endpoint metadata
at compile time rather than discovered at runtime:
[Route("/api/admin")][Authorize("admin")]public class AdminEndpoints{ [Get("/stats")] public Stats GetStats() => ;
[Get("/ping")] [AllowAnonymous] public string Ping() => "pong";
[Post("/import")] [EnableRateLimiting("uploads")] [RequireIpFilter("internal")] public Task<IActionResult> Import(ImportRequest request) => ;}The rules are worth knowing:
- A methods
[Authorize]adds to its classs rather than replacing it, so narrowing is additive and cannot accidentally widen. [AllowAnonymous]always wins, including over a fallback policy.- For CORS, rate limiting and IP filtering where two policies is not a thing a request can have
a methods attribute replaces the classs, and a
Disableanywhere wins over anEnable.
See Authorization, CORS, Rate Limiting and IP Filtering.
OpenAPI
Section titled OpenAPIThe generator emits the parameter names and sources, the body type, the return type and the
<summary> from the methods doc comment. [Produces] declares responses a method returning
IActionResult has deliberately hidden, [ApiTags] groups operations and [ApiExclude] hides one:
/// <summary>Fetches a single widget by id.</summary>[Get("/{id:int}")][Produces(200, typeof(Widget))][Produces(404, Description = "No widget has that id")]public Task<IActionResult> GetWidget(int id) => ;See OpenAPI.
Build diagnostics
Section titled Build diagnostics| Code | Severity | Meaning |
|---|---|---|
SWS001 |
Error | Invalid route template |
SWS002 |
Error | Parameter cannot be bound |
SWS003 |
Error | Unsupported return type |
SWS004 |
Error | Endpoint class or method is not reachable from generated code it must be public or internal, and not static, abstract or generic |
SWS005 |
Error | Duplicate route in this assembly |
SWS006 |
Warning | A type crosses an endpoint boundary as JSON but no JsonSerializerContext declares it |
SWS007 |
Error | More than one body parameter a request has only one body |
SWS008 |
Error | [FromRoute] names a token the template does not have |
SWS009 |
Warning | The template captures a token no parameter receives |
SWS010 |
Error | An IHttpEndpoint has no single Handle/HandleAsync method |
SWS011 |
Error | An IHttpEndpoint carries no verb attribute on the class |