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 [Handler].
REST is a separate exposure model from JSON-RPC and MCP. Those two are adapters over one
named request/reply bus (HandlerDispatcher), keyed by operation name; [HttpEndpoint] is route- and
verb-based, so it stays its own opt-in rather than a flag on [Handler]. A handler can carry both
without conflict.
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(BillingDbContext 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; a Result<ElarionFile> response is served as a binary
file download; otherwise success returns 200 OK with the JSON response body.
Request-driven SSE responses
[HttpEndpoint] generates a unary request/reply route. A response that is itself a lazy sequence instead
implements IStreamHandler<TRequest, TItem> and is mapped explicitly in the owning module's MapEndpoints
hook with a direct MapGet and ElarionHttpResults.ToStreamResult. Because that call site is hand-written
in your compilation, ASP.NET Core owns its route/query binding (and the Request Delegate Generator can
compile it ahead of time in an AOT host); it is intentional, not a missing generated endpoint.
endpoints.MapGet("reports/{reportId}/export", static (Guid reportId) =>
ElarionHttpResults.ToStreamResult<ExportRows.Query, ExportRows.Row>(new() {
ReportId = reportId,
}));The request stream still uses canonical source-generated JSON after AddElarionHttpJson(), but it is not an
OpenAPI/JSON-RPC/MCP operation. Read request-driven server streaming
for the choice between this cold per-request shape, client events, and actor-owned ordered streams, plus its
scope and failure semantics.
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).
MapElarionEndpoints 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:
[assembly: Elarion.AspNetCore.GenerateModuleBootstrapper]
// Program.cs
builder.Services.AddElarionHttpJson(); // canonical JSON + RFC 7807 responses (see below)
var app = builder.Build();
app.MapElarionEndpoints(app.Configuration); // module routes, gated per moduleThe generated registration is a concrete, per-handler RequestDelegate whose binding code the generator
emits at compile time (ADR-0071): no open generics, no reflection, and no dependency on ASP.NET Core's
reflection-based RequestDelegateFactory or its Request Delegate Generator — RDG is itself a source
generator and can never see another generator's output, so Elarion does its own binding and the endpoints
stay Native-AOT/trim-safe by construction. Each endpoint also carries its response metadata
(ProducesResponseTypeMetadata), the full set of ProblemDetails error responses, its owning module as an
OpenAPI tag, an inert idempotency marker for an [Idempotent] handler, and a metadata-only "API shape"
method that keeps ApiExplorer describing the endpoint's parameters and request body, so it shows up
correctly in OpenAPI.
Add the opt-in Elarion.AspNetCore.OpenApi package to turn that
into a served OpenAPI document (and a generated client) — the REST analog of the JSON-RPC schema and
TypeScript client.
JSON serialization with reflection off
The [HttpEndpoint] transport serializes both directions — request-body binding (a POST/PUT/PATCH JSON body)
and the success value (ElarionHttpResults uses TypedResults.Ok) — through ASP.NET's global
Microsoft.AspNetCore.Http.Json options, which Elarion otherwise leaves at their defaults. With
JsonSerializerIsReflectionEnabledByDefault=false (the AOT-friendly default) those options have no resolver for
your DTOs, so (de)serialization fails. Call AddElarionHttpJson() once to fix it:
builder.Services.AddElarion(builder.Configuration); // contributes the module JSON contexts
builder.Services.AddElarionHttpJson(); // aligns ASP.NET's HTTP JSON options with the canonical configIt mirrors the canonical naming knobs and source-generated resolver chain onto the HTTP JSON options, so requests,
responses (and your host's own minimal-API endpoints) resolve through the same contexts every transport uses — and
REST output matches the JSON-RPC/MCP transports for the same DTO. It is a deliberate, global alignment of the
app's minimal-API JSON, is idempotent, and runs in registration order — a host that needs different behavior calls
ConfigureHttpJsonOptions(…) after it and wins (applying to both directions consistently).
It also calls ASP.NET's AddProblemDetails() for you: the RFC 7807 error legs (ElarionHttpResults renders an
AppError via Results.Problem/Results.ValidationProblem) serialize through ASP.NET's own source-generated
ProblemDetailsJsonContext, which only that call contributes — without it, every error response 500s with
reflection off while the success legs work. A host that wants CustomizeProblemDetails just calls
AddProblemDetails(configure) itself; the two registrations compose in either order.
AddElarionOpenApi() already calls AddElarionHttpJson(), so a host that adds
OpenAPI does not need a separate call.
Per-module authorization and route conventions
MapElarionEndpoints 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 MapElarionEndpoints 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 generator classifies every bindable request member at compile time and emits the binding code itself (ADR-0071). The defaults cover the common cases with no annotation:
GET/DELETEbind the request from route tokens and the query string: a constructor parameter or settable/init property whose name matches a route token (case-insensitively) binds from the route; everything else binds from the query string.POST/PUT/PATCHbind the request from the JSON body.
Requiredness follows the DTO: a required member or a non-nullable member without a default must be
present on the wire; a nullable member is optional (absent → null); a constructor parameter default or a
property initializer is preserved when the wire says nothing. Supported member types for route/query/header/
form values are string, enums (parsed case-insensitively), and any IParsable<T> value type such as
Guid, int, or DateTimeOffset (invariant culture), plus their nullable forms and — from repeated query
keys — arrays of those. Anything else is a compile-time ELHTTP005 diagnostic, not a runtime surprise.
A request that fails to bind never reaches the handler: malformed or missing values produce the same RFC
7807 ValidationProblem shape as handler-tier validation — status 400 with a field-keyed errors map —
and a body with the wrong content type is rejected with 415. 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); body binding reads the minimal-API Http.Json options, so
AddElarionHttpJson() puts it on the canonical source-generated contexts.
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, binds them from the
request form (a non-form content type is a 415), and marks the endpoint .DisableAntiforgery() —
generator-bound endpoints don't participate in ASP.NET's automatic form antiforgery, so a host that wants
antiforgery on a form post enforces it deliberately. 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.
Files: downloads and uploads
A small file (an .xlsx export, a generated PDF, an avatar — as a rule of thumb, up to ~4 MB) is
plain handler data: ElarionFile in Elarion.Abstractions carries the content bytes, the content type,
and an optional file name. Declaring it says "this handler receives/returns a file" once, and every
transport does its best with it — HTTP serves a file response, the JSON surfaces carry a
base64 envelope, and the generated TypeScript
client speaks native File. Authorization, feature gating, and module scoping apply exactly like every
other handler; failures still map to RFC 7807:
[HttpEndpoint("exports/{list}")]
[RequirePermission("exports", "read")]
public sealed class GetExport(IMasterDataExporter exporter)
: IHandler<GetExport.Query, Result<ElarionFile>> {
public sealed record Query : IQuery {
public required string List { get; init; }
}
public async ValueTask<Result<ElarionFile>> HandleAsync(Query query, CancellationToken ct) {
var bytes = await exporter.BuildXlsxAsync(query.List, ct);
if (bytes is null)
return AppError.NotFound($"unknown export '{query.List}'");
return new ElarionFile(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") {
FileName = $"{query.List}.xlsx", // Content-Disposition: attachment; omit for inline content
};
}
}Uploads are the same type on the request side: an ElarionFile property binds from the JSON body's
base64 envelope, so one client code path covers every transport. When you specifically want HTTP
multipart binding (a plain <form>, FilePond), keep using IFormFile in the DTO
(customizing binding) — that is the HTTP-native escape hatch; ElarionFile is the
transport-neutral default.
In the OpenAPI document a file response advertises application/octet-stream with
type: string, format: binary (the concrete content type is per-payload at run time), so off-the-shelf
generated clients return a blob.
Large files: the staged-blob tier
ElarionFile is deliberately in-memory — the payload is buffered end to end (and base64-inflated on JSON
transports), which is wrong past a few megabytes. Large files move through the
staging blob area instead, and the handler only sees a pointer:
- Import: the client uploads through
MapElarionResumableBlobUploads(resumable) orMapElarionBlobUploadsinto the pending area, then passes the returned blob reference to the handler, which streams fromIBlobStore.OpenReadAsync. A blob the handler never commits is garbage-collected after its TTL. - Export: the handler streams the artifact into the store as a pending blob owned by the current user
and returns its
BlobRef; the client streams it down fromMapElarionBlobDownloads. An export nobody downloads expires on its own — the pending state doubles as temp-file semantics.
See ADR-0039 for the full two-tier rationale.
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 |
Binding-tier failures use the same contract before the handler runs: an unparseable route/query/header/form
value or a missing required member is a 400 ValidationProblem keyed by the wire name, and a request body
with a non-JSON content type is a bare 415.
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 [Handler] 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.
Transports
Map a handler over HTTP, JSON-RPC, MCP, or explicit unary/server-streaming gRPC service methods while keeping application logic transport-neutral.
Request-driven server streaming
Stream one request's lazy response over SSE or gRPC with IStreamHandler — and choose it deliberately over client events, ordered streams, or connections.