Elarion

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 markerIQuery 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 module

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

It 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/DELETE bind 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/PATCH bind 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) or MapElarionBlobUploads into the pending area, then passes the returned blob reference to the handler, which streams from IBlobStore.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 from MapElarionBlobDownloads. 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:

ErrorKindHTTP status
Validation400 (validation errors surface in the ProblemDetails errors map)
Forbidden403
NotFound404
Conflict409
BusinessRule422
Internal500

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.

On this page