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

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

The 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/DELETE bind the request from route tokens and the query string (via [AsParameters]).
  • POST/PUT/PATCH bind 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:

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

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.

On this page