HTTP endpoints
Mark a handler with [HttpEndpoint] and Elarion generates the minimal-API MapGet/MapPost mapping — unwrapping the Query/Command and mapping AppError to RFC 7807 status codes.
HTTP/REST is a first-class, optional transport in Elarion — the sibling of JSON-RPC.
Mark a handler with [HttpEndpoint] and the module bootstrapper generator emits the matching minimal-API
registration: it unwraps the nested Query/Command into route/query/body parameters and translates
the handler's Result<T> into a response — 200/204 on success,
or an RFC 7807 ProblemDetails with the right status code on failure.
Like JSON-RPC, the mapping lives in Elarion.AspNetCore (plus the [HttpEndpoint] attribute in
Elarion.Abstractions), so applications that don't need it pay nothing, and handlers stay
transport-neutral — the same handler can carry both [HttpEndpoint] and [RpcMethod].
Marking a handler
Add [HttpEndpoint] to a handler. The verb is inferred from the request's CQRS marker —
IQuery maps to GET, ICommand maps to POST — and the route is explicit:
[HttpEndpoint("clients/{id}")] // GET (inferred from IQuery)
public sealed class GetClient(IAppDbContext db)
: IHandler<GetClient.Query, Result<GetClient.Response>> {
public sealed record Query : IQuery {
public required Guid Id { get; init; }
}
public sealed record Response(Guid Id, string Name);
public ValueTask<Result<Response>> HandleAsync(Query query, CancellationToken ct) { /* ... */ }
}
[HttpEndpoint("clients")] // POST (inferred from ICommand)
public sealed class CreateClient : IHandler<CreateClient.Command, Result<CreateClient.Response>> { /* ... */ }Verb precedence is: an explicit verb wins; otherwise the request's ICommand/IQuery marker decides;
otherwise the generator reports ELHTTP004 (a request that is neither marked nor given an explicit
verb cannot be mapped). Naming or nesting the request Command/Query has no effect on the verb.
[HttpEndpoint(HttpVerb.Delete, "clients/{id}")] // explicit verb; request needs no marker
public sealed class DeleteClient : IHandler<DeleteClient.Command, Result<DeleteClient.Response>> { /* ... */ }The generator reads the request and response from the IHandler<TRequest, Result<TResponse>>
interface (the success type is unwrapped from Result<T>); the types may be nested or top-level. A
Response with no properties returns
204 No Content; otherwise success returns 200 OK with the response body.
Hosting
[HttpEndpoint] handlers are mapped through the generated module bootstrapper, so they are
feature-flag-gated like everything else a module owns (modules are the single hosting path).
MapAllEndpoints calls each enabled module's MapEndpoints hook and its generated Map{Module}Http
method; a module disabled with Modules:{Name}:Enabled = false drops its routes:
[GenerateModuleBootstrapper]
public static partial class ModuleBootstrapper;
// Program.cs
builder.Services.AddProblemDetails(); // RFC 7807 responses
var app = builder.Build();
ModuleBootstrapper.MapAllEndpoints(app, app.Configuration); // module routes, gated per moduleThe generated registration is a strongly-typed minimal-API lambda per handler (no open generics, no
runtime reflection binding), so it stays AOT/trimming friendly and works with the ASP.NET Core Request
Delegate Generator. Each endpoint is also annotated with .Produces<TResponse>(...) and the full set of
.ProducesProblem(...) responses, so it shows up correctly in OpenAPI.
Per-module authorization and route conventions
MapAllEndpoints maps every enabled module's routes onto the same route builder, leaving authentication
and authorization to the host's middleware and global policies. There are no per-module URL prefixes by
default — each [HttpEndpoint("...")] carries its own complete route template, so modules share a flat
URL space and any module can declare a route under any path (the generator only guards against duplicate
verb + route pairs).
When a module needs its own authorization policy or route conventions, that is a module concern: the
module declares an optional ConfigureEndpointGroup hook that returns the builder its generated routes
(and its MapEndpoints hook) are mapped onto. The host keeps calling MapAllEndpoints once; the module
owns its group:
[AppModule]
public static partial class BillingModule {
// Applies this module's conventions/policy to its generated [HttpEndpoint] routes.
public static IEndpointRouteBuilder ConfigureEndpointGroup(IEndpointRouteBuilder root) =>
root.MapGroup("").RequireAuthorization("billing"); // policy only — no prefix
}A conventions-only group (MapGroup("")) attaches a policy or metadata without a prefix, so the
module's routes keep their full templates and the flat URL space is preserved. A module that wants to own
a URL segment can instead return root.MapGroup("/billing") — that prefixes all of its generated
routes, the explicit trade for a module that wants its own path.
This is the same seam as a module's own MapEndpoints hook: the host owns global middleware order, the
module owns its group, and the generator never reads [Authorize]/[AllowAnonymous] from handlers.
Per-endpoint policy is out of scope — a handler that needs a different policy belongs in its own module
or the hand-written MapEndpoints hook.
Binding
The defaults cover the common cases with no annotation:
GET/DELETEbind the request from route tokens and the query string (via[AsParameters]).POST/PUT/PATCHbind the request from the JSON body.
The request DTO stays the host's responsibility to keep in a JsonSerializerContext (the same
requirement as JSON-RPC, since reflection-based JSON is off by default).
Customizing binding
When you need more control — a header value, a renamed query parameter, or a file upload — opt the DTO
into ASP.NET Core's binding-source attributes by referencing the
Microsoft.AspNetCore.Http.Abstractions package (the [From*] attributes come with the
Microsoft.AspNetCore.App framework reference). Decorating any property switches that endpoint to
[AsParameters] binding:
[HttpEndpoint("clients/{id}/avatar")] // multipart upload
public sealed class UploadAvatar : IHandler<UploadAvatar.Command, Result<UploadAvatar.Response>> {
public sealed record Command {
public required Guid Id { get; init; } // from the route token
public required IFormFile File { get; init; }
}
public sealed record Response(string Url);
}The generator detects IFormFile/IFormFileCollection and [FromForm] members and adds
.DisableAntiforgery() automatically. For a mutation that mixes a path id with a JSON payload, nest the
payload in a [FromBody] property:
public sealed record Command {
[FromRoute] public required Guid Id { get; init; }
[FromBody] public required UpdateClientBody Body { get; init; }
}This opt-in is the deliberate tradeoff: the DTO references an ASP.NET abstraction package only when it needs HTTP-specific binding, and only then.
Errors map once, centrally
Handlers stay transport-agnostic and return Result<T>; the
generated endpoint translates a failed AppError to an RFC 7807 ProblemDetails response. The status
code comes from AppError.Kind, mirroring how JSON-RPC maps the same kinds to its error codes:
ErrorKind | HTTP status |
|---|---|
Validation | 400 (validation errors surface in the ProblemDetails errors map) |
Forbidden | 403 |
NotFound | 404 |
Conflict | 409 |
BusinessRule | 422 |
Internal | 500 |
HTTP or JSON-RPC?
Both transports map the same handlers, so the choice is per-handler, not per-app. Reach for
[HttpEndpoint] when you want resourceful URLs, HTTP caching/CDN semantics, file uploads, or
third-party/public consumers; reach for [RpcMethod] when one team owns both ends and
wants an operation-shaped, end-to-end-typed contract with a generated TypeScript client. A handler can
expose both.