HTTP APIs (Rask.Api / Rask.Api.Client)
Sometimes you need a real HTTP API — a mobile client, a partner integration, a webhook receiver, or
just curl. CQRS remote dispatch deliberately gives you none of that: its whole pitch is
that there is no /api/* endpoint to write, and its route is a framework-internal path carrying a
CLR type name. That is the right answer when the only caller is your own browser code, and the wrong
one when the caller is somebody else.
So write the endpoint the ordinary ASP.NET way. Rask does two things with it: hosts it properly, and generates a typed client so your code never writes the URL twice.
- Hosting —
AddRaskApi()/MapRaskApi(), and the 404 that was missing - The typed client — one client per controller, generated from the declaration
- Minimal APIs — the same, read from the
MapGetcalls themselves - Validation —
[Required]and yourAbstractValidator<T>, async rules included - What gets a client method — and what is reported instead
Hosting
There is nothing to wire. Like every other battery, referencing the framework is what turns this on — write a controller and it answers:
var app = RaskApp.Create(args);
app.Run<App>();
Nothing about the endpoint itself is Rask-specific either:
[ApiController]
[Route("api/posts")]
public sealed class PostsController(AppDb db) : ControllerBase
{
[HttpGet("{id:int}")]
public async Task<ActionResult<Post>> Get(int id) =>
await db.Posts.Get(id) is { } post ? post : NotFound();
}
Order is not what makes this work
It is worth being blunt about this, because Rask's own docs used to say the opposite. Endpoint routing
matches on precedence, never on registration order, and every route you write is more specific than
Rask's /{**path} catch-all. Your endpoints answer from either side of MapRask. MapEndpoints is a
readable place to put them, not a fix for a bug.
What order cannot fix — and what MapRaskApi() does — is a request under your API prefix that
matches nothing. Without a guard it reaches the catch-all and renders the app, so a mistyped or
deleted route answers 200 with a web page and the caller's JSON parse fails a long way from the
cause. MapRaskApi() answers 404 with an RFC 9457 problem document under ApiOptions.Prefix
(default /api) and leaves every other path alone.
That guard is an ordinary endpoint rather than a fallback, deliberately: a fallback sits at
int.MaxValue and would lose to the catch-all, while at the same order /api/{**rest} outranks
/{**path} — and loses in turn to every real route beneath it.
app.Configure(c =>
{
c.Api.Off(); // no HTTP endpoints in this app at all
c.Api.Configure(o =>
{
o.Prefix = "/services"; // where this app's endpoints live
o.NotFound = false; // answer for unmatched paths yourself
o.Controllers = false; // minimal APIs only; never discover a controller
});
});
An app assembled by hand, without RaskApp, calls AddRaskApi() and MapRaskApi()
itself. MapRaskApi() returns the endpoint group, so rate limiting, CORS or output caching attach to
the whole API in one line: app.MapEndpoints(e => e.MapRaskApi().RequireRateLimiting("api"));
What gets registered
AddMvcCore().AddDataAnnotations(), not AddControllers(). An API controller needs the core —
routing, model binding, the JSON formatters, the [ApiController] conventions — while AddControllers
layers on the API explorer, CORS services and formatter mappings: machinery for OpenAPI documents,
cross-origin policies and .json-style URL suffixes that an app gets whether it asks or not.
DataAnnotations is the one addition, because dropping it changes behaviour rather than weight: a
[Required] on a request body silently stops being enforced, and an endpoint that quietly accepts what
it used to reject is worse than a heavier registration. (Checked by
A_required_member_is_still_enforced_by_the_lean_registration, which goes red without it.) An app that
wants CORS or an OpenAPI document adds AddCors() or AddApiExplorer() — a line it would have written
anyway.
The typed client
The endpoint you already wrote is the source of truth. Rask.Api.Client reads it and generates one
client per controller — PostsController becomes PostsClient — so a component asks for the data
rather than for a URL:
host.Services.AddRaskApiClient();
public sealed class PostDetail(PostsClient posts) : Component
{
[Prop] public int Id { get; init; }
private Post? _post;
protected override async Task OnMount() => _post = await posts.Get(Id);
public override Component Render() =>
_post is null ? Div["Loading…"] : Article[H1[_post.Title]];
}
Compare that with what docs/http-and-files.md describes, which is still exactly right for calling
somebody else's API:
await http.GetFromJsonAsync<Post>($"/api/posts/{id}"); // four things that can drift
The URL, the verb, the request shape and the response shape are all restated at the call site, and nothing checks them against the server. Rename the route on the controller and this compiles fine and 404s at run time. With the generated client it does not compile — which is the whole point.
The codecs are the reflection-free ones the CQRS wire uses, so a shape means the same thing on either, and the client publishes clean under the WASM/AOT trimmer.
In a one-project wasm-hosted app
The client reaches the browser app in Client/ on its own. That is worth spelling out, because the
controller it was generated from cannot: the browser app compiles only Client/ and Shared/, so a
generator running there would see no controllers at all.
What crosses instead is the file the server's own generator wrote. EmitCompilerGeneratedFiles
puts it on disk, and the browser app's generated project compiles that exact file — so the two halves
cannot disagree about the client, because there is only one of it. The client runtime
(Rask.Api.Client) is added to the browser app automatically when that file exists; you do not declare
it twice.
Deliberately unlike Rask.Cqrs.Client, which must be kept out of the server: that split
exists because AddRaskCqrsClient() rewrites a process-wide registry, so a server holding it would
bounce its own messages straight back out. A typed API client has no such property — it dials routes
that are public anyway.
The shapes that cross the wire live in
Shared/. A request or response type declared anywhere else on the server side is invisible to the browser app, so the generated client returns a type it cannot compile — and you meet it asCS0246inside generated code you never wrote. Same rule as a CQRS message record: the handler is server-only, the shape is shared. In practice:Post,NewPostand friends inShared/,PostsControlleroutsideClient/andShared/.
Securing an endpoint
[Authorize] works the way it does in any ASP.NET app — on the controller, on the action, with a policy
or roles, and [AllowAnonymous] to open one route on an otherwise protected controller:
[ApiController]
[Route("api/orders")]
[Authorize]
public sealed class OrdersController(AppDb db) : ControllerBase
{
[HttpGet("{id:int}")]
public Task<ActionResult<Order>> Get(int id) => ...;
[HttpDelete("{id:int}")]
[Authorize(Roles = "admin")]
public Task Cancel(int id) => ...;
}
Nothing about it is Rask-specific, and the lean registration does not weaken it: enforcement is asserted
over real HTTP in AuthorizeTests — 401 anonymous, 403 for a role the caller lacks, 200 for one they
hold — because an [Authorize] that silently stopped being enforced would still look like protection
in the source.
Your app still calls AddAuthentication/AddAuthorization and UseAuthentication/UseAuthorization;
the accounts battery registers them, so a scaffolded app already has them. Their
absence fails loudly at startup rather than quietly per request.
On the client side, attach the token with ApiClientOptions.ConfigureRequest — it receives the
request rather than the HttpClient, so a token is scoped to the call instead of becoming ambient state
shared by everything that resolves the same client.
Unlike CQRS remote dispatch, none of this is re-implemented: there, two shared endpoints cannot carry
per-message metadata, so Rask.Cqrs.Server reads [Authorize] off the handler at compile time and
enforces it imperatively. An API controller is its own endpoint, so ASP.NET's own authorization
applies and Rask stays out of the way.
The caller is also ambient. In a Rask app with its data layer on, every HTTP request — a controller, a
minimal API endpoint, a CQRS endpoint — runs with its signed-in user reachable from
Current: Current.UserId is the caller's id, and a
tenant-scoped read filters to the caller's tenant with nothing passed to it. It is set after
authentication, from HttpContext.User, so an action and the static factory it calls agree on who is asking —
the factory has no User property to read. ControllerBase.User is still there, and says the same.
Failures
A call that does not succeed throws ApiException. StatusCode is null when the request never
reached a server at all — DNS, TLS, a dropped connection, a timeout — with the cause in
InnerException. A status means the server answered and said no. Those need different handling, and an
exception that blurred them would make "is it down, or am I wrong?" unanswerable:
catch (ApiException ex) when (ex.StatusCode is null) { /* offline: queue it */ }
catch (ApiException ex) when (ex.StatusCode == 404) { /* gone: show empty */ }
catch (ApiException ex) when (ex.Errors is { } fields) { /* rejected: show them */ }
Errors is populated only for a validation rejection, keyed by field with the empty key
holding rules about the request as a whole — the same map RemoteDispatchException.Errors carries. It
is the one failure whose text is safe to show: a validation message was authored for whoever sent the
request, which is why it crosses the wire when handler exception text does not.
ApiClientOptions.ConfigureRequest is the hook for a bearer token or a tenant header. It receives
the request rather than the HttpClient, deliberately: a token on the client is ambient state shared
by everything that resolves it, while a token here is scoped to the call being made.
Minimal APIs
Read from the MapGet/MapPost/… invocations themselves, with no attribute and no registration:
app.MapEndpoints(e =>
{
e.MapGet("/api/widgets/{id:int}", async (int id, AppDb db) =>
await db.Widgets.Get(id) is { } w ? TypedResults.Ok(w) : Results.NotFound());
e.MapPost("/api/widgets", (Widget body, AppDb db) => db.Add(body));
});
A minimal API has no controller to be named after, and most live in a Program.cs whose enclosing type
is Program — a name that would tell a reader nothing. So they group by route: everything under
/api/widgets lands on WidgetsClient. The method name comes from the verb and the route past that
grouping segment, so GET /api/widgets/{id} becomes GetById. Chain .WithName("…") to choose it
yourself — that is ASP.NET's own way of naming an endpoint, not a Rask invention:
e.MapDelete("/api/widgets/{id:int}/tag", (int id) => TypedResults.NoContent())
.WithName("Untag"); // widgets.Untag(3)
TypedResults is read properly. Ok<T>, NoContent and Results<Ok<T>, NotFound> all carry the
response type in the signature, and the alternative carrying a body supplies the client's return type.
Results.Ok(x) — the untyped IResult — does not, and reports RASK070.
An endpoint that is not part of the app's API says so with .ExcludeFromDescription() — the same call
that keeps it out of OpenAPI — and the client leaves it out: no method, and no RASK070 about one.
Validation
An endpoint is validated like everything else in a Rask app, with nothing declared: the body's
DataAnnotations attributes, then the AbstractValidator<T> written for its type — including
MustAsync rules, which neither ModelState nor Validator.TryValidateObject can run.
public sealed class NewOrder
{
[Required] public string? Reference { get; set; }
[Range(1, 100)] public int Quantity { get; set; }
}
A failure answers 400 application/problem+json with an errors object keyed by field, under the
stable type documented at a rejected request — the same document a
rejected CQRS dispatch sends, so a client handles either. The generated client surfaces it as
ApiException.Errors.
Controllers are covered wherever they live. A minimal API is covered when it is mapped through
app.MapEndpoints(e => …); one mapped straight onto the built WebApplication asks for the convention
by name with .RequireRaskValidation(), because ASP.NET has no global endpoint filter.
Full detail — the off switch, what it leaves to ASP.NET, and the client side — is in validation.md.
What gets a client method
Everything whose shape can be written as one, and a diagnostic naming the reason for everything else. A method silently missing from a client reads as a broken generator, so nothing is skipped in silence.
| Written on the server | In the client |
|---|---|
ActionResult<Post>, Task<Post>, Post |
Task<Post> |
Task, void, NoContent |
Task |
Ok<Post>, Results<Ok<Post>, NotFound> |
Task<Post> |
IActionResult + [ProducesResponseType(typeof(Post), 200)] |
Task<Post> |
IActionResult alone |
nothing — RASK070 |
Parameters follow ASP.NET's own [ApiController] binding rules: a name matching a route token binds
from the route, a simple type from the query, and the one complex type left is the body. [FromRoute],
[FromQuery], [FromBody] and [FromHeader] say so explicitly.
Anything the caller does not supply is not a client parameter — [FromServices], HttpContext,
CancellationToken, ClaimsPrincipal, IFormFile. An injected service is recognised without
[FromServices] too: a parameter only reaches the client's signature if its type is one the wire can
carry, and an interface or abstract type is exactly the shape of something that comes from the
container.
Two things worth knowing because they are easy to get wrong by hand:
- Optional parameters keep the server's default. An
int page = 1generatespage = 1, notpage = default— which would sendpage=0whenever a caller omitted it and quietly override the server's own default with a zero that type-checks everywhere. - The client asks for JSON. An action returning
stringotherwise answerstext/plain, because ASP.NET'sStringOutputFormatterwins content negotiation, andreturn "ok"would arrive asokrather than"ok".
Diagnostics: RASK067 (no wire encoding), RASK068 (endpoint skipped, with the reason), RASK069 (two endpoints claiming one client method), RASK070 (response type not statically known).
See also: CQRS for when the only caller is your own browser code and no HTTP surface needs to
exist, HTTP & files for calling APIs you did not write, and
Authentication for securing endpoints with [Authorize].