[ Web Proxy ]
URL:
Viewing: https://shinylib.net/httpserver/endpoints [Back]  [Original]

Typed Endpoints | Shiny.NETSkip to content
Search
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

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.

NuGet package Shiny.Net.HttpServer [NuGet package Shiny.Net.HttpServer]
Terminal window
dotnet add package Shiny.Net.HttpServer

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

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

The generator emits one extension method per class and one for the assembly:

app.MapWidgetEndpoints(); // just this class
app.MapMyAppEndpoints(); // every [Route] class in the assembly

The assembly-wide name comes from the assembly name Sample.Api produces MapSampleApiEndpoints().

Binding is by convention, with attributes as the escape hatch. For each parameter, in order:

  1. HttpContext, HttpRequest, HttpResponse and CancellationToken are handed over directly.
  2. A name matching a route token, where the type can be parsed from a string, binds from the route.
  3. Anything else a string can become IParsable<T>, an enum, a nullable of either, or an array of them binds from the query string.
  4. A complex type on a verb that carries a body binds from the JSON body (at most one per method).
  5. 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.

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

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

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

Everything 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 Disable anywhere wins over an Enable.

See Authorization, CORS, Rate Limiting and IP Filtering.

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

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

Web Proxy Viewer  |  New URL  |  Original Page