Skip to main content

HTTP Endpoints (Warp.Http)

Moberg.Warp.Http is an optional package that exposes Warp IRequest<TResponse> and IStreamRequest<TResponse> handlers as ASP.NET Minimal API endpoints — annotate the handler class, run MapWarpHttp(), you have an HTTP endpoint. Source-generated dispatch (no per-request reflection); independent of Warp.UI.

IJob and IMessage cannot be HTTP-exposed — background-work types have async fire-and-forget semantics that don't fit synchronous request/response. The pattern for "submit a job via HTTP" is a thin IRequest<Guid> wrapper that calls IPublisher.Enqueue (see § Submit a job via HTTP).

Install

<PackageReference Include="Moberg.Warp.Http" Version="..." />

The NuGet ships the runtime library plus the source generator that discovers [WarpHttp...]-tagged handler classes in your assembly.

Quick start

using Microsoft.AspNetCore.Mvc; // [FromRoute], [FromQuery], [FromHeader], [FromBody]
using Warp.Core.Handlers;
using Warp.Http;

// Define the request type — this is the public contract.
public sealed record GetOrder([FromRoute] Guid Id) : IRequest<OrderDto>;

// Tag the handler class with the HTTP method + route.
[WarpHttpGet("/orders/{id}")]
public sealed class GetOrderHandler : IRequestHandler<GetOrder, OrderDto>
{
public Task<OrderDto> HandleAsync(GetOrder request, CancellationToken ct)
=> Task.FromResult(new OrderDto(request.Id, "pending"));
}

Wire up at startup:

builder.Services.AddWarpHttp(); // registers options + writers
// ... your other Warp registrations ...
app.MapWarpHttp(); // discovers and maps tagged handlers

That's it. GET /orders/<guid> is now live and returns 200 + OrderDto JSON.

How it works

The source generator finds every class tagged with [WarpHttp...] that implements IRequestHandler<TReq, TRes> or IStreamRequestHandler<TReq, TRes>, and emits a strongly-typed delegate per attribute. ASP.NET Minimal API parses route values, query strings, headers, and body using its full binding pipeline — including IParsable<T>, TryParse, query-string arrays, and content negotiation.

After binding, the generated delegate dispatches via IMediator.Send (or CreateStream), so the existing IPipelineBehavior chain runs unchanged: auth, validation, logging, anything you've registered for in-memory Send calls also runs on the HTTP path.

HTTP request
↓ ASP.NET Minimal API binding (route, query, header, body)
↓ Generated lambda — constructs TRequest, calls IMediator.Send
↓ IPipelineBehavior chain (your auth / validation / logging)
↓ Your IRequestHandler.HandleAsync
↓ JSON / SSE response

Response semantics

Handler kindStatusBody
IRequest<TResponse> non-Unit200JSON of TResponse
IRequest<Unit>204empty
IStreamRequest<TResponse>200text/event-stream (one data: per item)

A response type can implement IHttpResponseShape to override the default status / set headers / set Location:

public sealed record CreatedOrder(Guid Id, string CustomerName) : IHttpResponseShape
{
public void Apply(HttpContext context)
{
context.Response.StatusCode = StatusCodes.Status201Created;
context.Response.Headers.Location = "/orders/" + Id;
}
}

Apply runs after the handler returns and before the JSON body is serialized. Only fires for IRequest<TResponse> non-Unit responses — not for IRequest<Unit> (no body) or streams (status fixed for the stream's lifetime).

Binding

Warp.Http delegates all parameter binding to ASP.NET Minimal API. Use the standard attributes from Microsoft.AspNetCore.Mvc:

SourceAttribute
Route token (e.g. {id})[FromRoute]
Query string[FromQuery]
Request header[FromHeader(Name = "X-Foo")]
JSON body[FromBody]
(default for body verbs without other attrs)the request itself becomes the body
(default for non-body verbs without other attrs)route-token-name match → [FromRoute], otherwise [FromQuery]

Binding shapes

The generator picks one of three lambda shapes depending on what your request type looks like:

1. Whole-body POST — request type with no per-property attributes on a body verb:

public sealed record CreateOrder(string CustomerName, List<LineItem> Items) : IRequest<OrderDto>;

[WarpHttpPost("/orders")]
public sealed class CreateOrderHandler : IRequestHandler<CreateOrder, OrderDto> { ... }

ASP.NET deserializes the entire JSON body into CreateOrder. The natural shape for plain POST DTOs.

2. Decomposed ([AsParameters]) — request type whose properties carry only [FromRoute] / [FromQuery] / [FromHeader]:

public sealed record ListOrders(
[FromQuery] int Page,
[FromQuery] int PageSize,
[FromHeader(Name = "X-Tenant-Id")] Guid TenantId) : IRequest<ListOrdersResponse>;

[WarpHttpGet("/orders")]
public sealed class ListOrdersHandler : IRequestHandler<ListOrders, ListOrdersResponse> { ... }

ASP.NET binds each property from its declared source via [AsParameters]. Records with primary constructors and classes with property setters both work.

3. Mixed body + route/query/header — class with a [FromBody] property and other source attributes:

public sealed class SubmitOrder : IRequest<OrderDto>
{
[FromRoute(Name = "tenantId")]
public Guid TenantId { get; set; }

[FromBody]
public SubmitOrderBody Body { get; set; } = new(string.Empty);
}

public sealed record SubmitOrderBody(string Description);

[WarpHttpPost("/orders/{tenantId}/submit")]
public sealed class SubmitOrderHandler : IRequestHandler<SubmitOrder, OrderDto> { ... }

The generator emits explicit lambda parameters per source and constructs SubmitOrder from the bound parts. ASP.NET's [AsParameters] doesn't support [FromBody] properties directly, so the generator handles this case explicitly.

Tip: for mixed binding, prefer classes with property setters over records with primary constructors. Attributes on record positional parameters apply to the parameter, not the synthesized property, which can confuse [AsParameters]. Classes with { get; set; } or { get; init; } properties are unambiguous.

Records vs classes

Both work. Pick whatever matches your codebase style:

  • Record with primary ctor: record GetOrder([FromRoute] Guid Id) : IRequest<OrderDto>;
  • Class with init-only properties: class GetOrder : IRequest<OrderDto> { [FromRoute] public Guid Id { get; init; } }
  • Class with full setters: class GetOrder : IRequest<OrderDto> { [FromRoute] public Guid Id { get; set; } }

For the mixed-binding shape, classes are recommended (see tip above).

Verbs

[WarpHttpGet("/path")] // GET
[WarpHttpPost("/path")] // POST
[WarpHttpPut("/path")] // PUT
[WarpHttpPatch("/path")] // PATCH
[WarpHttpDelete("/path")] // DELETE

GET and DELETE never read the request body — only POST/PUT/PATCH bind whole-body or mixed-body requests.

Route template constraints

Route constraints work because they're pure ASP.NET — Warp.Http passes the template through:

[WarpHttpGet("/orders/{id:guid}")] // matches only valid GUIDs; non-GUID returns 404
[WarpHttpGet("/users/{age:int:min(0)}")] // matches non-negative ints

Binding errors

Malformed JSON, unparseable values, missing required route tokens — all surface as ASP.NET's standard BadHttpRequestException (400) or whatever your registered exception middleware translates them to. Warp.Http does not impose its own error envelope; if your app has AddProblemDetails() and UseExceptionHandler(), those produce the response.

Submit a job via HTTP

IJob and IMessage are explicitly not HTTP-exposable — tagging a handler whose request type implements either of them is a compile-time error (WHTTP001). The reason: synchronous HTTP request/response and asynchronous fire-and-forget background work have different success criteria. A POST /jobs/... endpoint that returns 202 might or might not surface a job ID, might or might not block, might or might not retry — every choice is wrong for someone.

The pattern is to write a thin wrapper IRequest<Guid> whose handler enqueues the real job:

public sealed record EnqueueReport(Guid TenantId) : IRequest<Guid>;

[WarpHttpPost("/reports/generate")]
public sealed class EnqueueReportHandler(IPublisher publisher)
: IRequestHandler<EnqueueReport, Guid>
{
public async Task<Guid> HandleAsync(EnqueueReport req, CancellationToken ct)
{
var jobId = await publisher.Enqueue(new GenerateReportJob(req.TenantId));
await publisher.SaveChangesAsync(ct);
return jobId;
}
}

The HTTP response is 200 + "<job-guid>". The actual work runs in your worker pool. The wrapper is explicit about its semantics — no framework magic.

Streaming (SSE)

IStreamRequest<TResponse> becomes a text/event-stream endpoint. Each yielded item becomes a data: <json>\n\n frame:

public sealed record OrderEventFeed([FromQuery] Guid TenantId) : IStreamRequest<OrderEvent>;

[WarpHttpGet("/orders/feed")]
public sealed class OrderEventFeedHandler : IStreamRequestHandler<OrderEventFeed, OrderEvent>
{
public async IAsyncEnumerable<OrderEvent> HandleAsync(
OrderEventFeed request,
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var evt in _events.Subscribe(request.TenantId, ct))
{
yield return evt;
}
}
}

HttpContext.RequestAborted is plumbed through to the handler — when the client disconnects, the IAsyncEnumerable enumerator's cancellation token fires.

Versioning aliases (multi-attribute)

A handler class may carry multiple [WarpHttp...] attributes. Each must specify a unique Name:

[WarpHttpPost("/v1/orders", Name = "CreateOrderV1")]
[WarpHttpPost("/v2/orders", Name = "CreateOrderV2")]
public sealed class CreateOrderHandler : IRequestHandler<CreateOrder, OrderDto> { ... }

ASP.NET requires unique route names per endpoint; missing Name on any of multiple attributes is a compile-time error (WHTTP002).

Named groups

Group attributes select a subset of endpoints to register on a particular IEndpointRouteBuilder. Useful for exposing a handler under a sub-path or applying group-level middleware:

[WarpHttpPost("/orders", Group = "public")]
public sealed class CreateOrderPublicHandler : IRequestHandler<CreateOrder, OrderDto> { ... }

[WarpHttpPost("/admin/users", Group = "admin")]
public sealed class CreateAdminUserHandler : IRequestHandler<CreateAdminUser, Guid> { ... }
app.MapWarpHttp(); // registers null-group handlers only
app.MapGroup("/api/public").RequireAuthorization("publicPolicy").MapWarpHttp("public");
app.MapGroup("/internal/admin").RequireAuthorization("adminPolicy").MapWarpHttp("admin");

MapWarpHttp(group) matches strictly — null matches null, "public" matches "public". No overlap. Calling MapWarpHttp(group) twice on the same IEndpointRouteBuilder instance with the same group throws InvalidOperationException at startup.

Auth

Place [Authorize] or [AllowAnonymous] on the handler class — Warp.Http surfaces them as ASP.NET endpoint metadata, so they compose with group-level RequireAuthorization() exactly as Minimal API does:

[Authorize(Policy = "OrdersWrite")]
[WarpHttpPost("/orders")]
public sealed class CreateOrderHandler : IRequestHandler<CreateOrder, OrderDto> { ... }
app.MapGroup("/api").RequireAuthorization().MapWarpHttp();
// Per-handler [AllowAnonymous] overrides the group's RequireAuthorization() on a single endpoint:

[AllowAnonymous]
[WarpHttpGet("/api/health")]
public sealed class HealthCheckHandler : IRequestHandler<HealthCheck, HealthStatus> { ... }

OpenAPI / Swagger

Each registered endpoint emits standard ASP.NET endpoint metadata:

  • .Accepts<TRequest>("application/json") for body verbs
  • .Produces<TResponse>(200) (or .Produces(204) for IRequest<Unit>)
  • .WithName(name) from the attribute
  • .WithTags(...) derived from the route's first segment (e.g. /api/orders/{id}api)

Swashbuckle, NSwag, and Microsoft.AspNetCore.OpenApi discover these automatically. No additional configuration needed.

Pipeline behaviors

The HTTP path runs IPipelineBehavior<TRequest, TResponse> — the same chain that runs for in-memory IMediator.Send. Auth, validation, logging, anything you've registered as a pipeline behavior runs on HTTP requests for free:

public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> HandleAsync(TRequest request, RequestHandlerDelegate<TRequest, TResponse> next, CancellationToken ct)
{
logger.LogInformation("Handling {RequestType}", typeof(TRequest).Name);
var response = await next(request, ct);
logger.LogInformation("Handled {RequestType}", typeof(TRequest).Name);
return response;
}
}

This logs every IRequest<T> whether dispatched in-memory or via HTTP.

RetryPipelineBehavior (constraint where TRequest : IJob) and ConcurrencyPipelineBehavior (is not IJob runtime check) self-scope to the worker path and never run on HTTP — appropriately, since blocking the HTTP request thread for retry delays would be a bug.

Diagnostics

IDSeverityCondition
WHTTP001ErrorHandler class tagged with [WarpHttp...] either doesn't implement IRequestHandler<,> / IStreamRequestHandler<,>, or its request type implements IJob / IMessage (background-work types cannot be HTTP-exposed).
WHTTP002ErrorHandler class has multiple [WarpHttp...] attributes but at least one is missing Name = "...". ASP.NET requires unique route names per endpoint.

Independence from Warp.UI

Moberg.Warp.Http is structurally independent of Moberg.Warp.UI. The dashboard ships its own endpoints under /warp and is unrelated to this feature. You can use Warp.Http without Warp.UI, and vice versa.