Skip to content
Shiny.Net.HttpServer v1 - A lightweight feature rich HTTP Server - Tunnels, Websockets, AOT, ASPNET Featureset, & Works EVERYWHERE!Let me see!

Shiny.Mediator

NuGet package Shiny.Net.HttpServer.Mediator
Terminal window
dotnet add package Shiny.Net.HttpServer.Mediator
Frameworks
.NET
.NET MAUI
Operating Systems
Android
iOS
macOS
Windows
Linux

If you already write Shiny.Mediator handlers, this publishes them over HTTP. It is the same shape as Shiny.Mediator.AspNet — a group attribute on the handler, a verb attribute on Handle — and it runs where ASP.NET Core cannot.

The generator is inside this package. One reference gets you the attributes and the code that reads them; there is no second package to forget.

[MediatorHttpGroup("/api/widgets", Tags = ["Widgets"])]
public class WidgetHandlers(IWidgetStore store) : IRequestHandler<GetWidget, Widget>
{
[MediatorHttpGet("/{id:int}")]
public Task<Widget> Handle(GetWidget request, IMediatorContext context, CancellationToken ct)
=> store.FindAsync(request.Id, ct);
}
public record GetWidget(int Id) : IRequest<Widget>;
builder.Services.AddShinyMediator(_ => { });
var app = builder.Build();
app.MapGeneratedMediatorEndpoints(); // every handler in the assembly

MapGeneratedMediatorEndpoints() registers the lot. Each handler also gets its own Map{HandlerName}MediatorEndpoints() if you want to mount them separately.

This is the one place the integration deliberately differs from the ASP.NET package, and it is not a style choice. ASP.NET binds a contract with [AsParameters] and [FromBody], which is reflection over a delegate’s parameters — annotated RequiresDynamicCode, and unusable in a trimmed app. Here the binding is written out at compile time, member by member, exactly as the typed endpoint generator does.

Verb Where the contract comes from
GET, DELETE Each member is read individually — from a route token if the template has one with that name, otherwise from the query string
POST, PUT, PATCH The whole contract is deserialized from the JSON body, then any route token naming a member is applied over the top

So this works, and the {id} in the URL wins over whatever the body claimed:

[MediatorHttpPut("/{id:int}")]
public Task<Widget> Handle(RenameWidget request, IMediatorContext context, CancellationToken ct) => …;
public record RenameWidget(int Id, string Name) : IRequest<Widget>;

The URL is the authority on which resource is being addressed, so a body that disagrees with the path it was sent to does not get to win.

Applying that override needs somewhere to put it. A record is rebuilt with a with expression; a class needs a settable property. An init-only property on a non-record cannot be reached once the body has been read, and that is a build warning (SWM008) rather than a surprise at runtime.

A member bound from the route or query must be something a string can become — a primitive, an enum, a Guid, anything implementing IParsable<T>, or an array of those. Anything else is SWM003 at build time, with the fix in the message: move the endpoint to POST/PUT so the contract arrives as JSON. Nullable members and members with a default are optional; everything else is required and a missing value is a 400.

An ICommand has no result, so it answers with a status code and no body — 204 by default:

[MediatorHttpDelete("/{id:int}")]
public Task Handle(DeleteWidget command, IMediatorContext context, CancellationToken ct) => …;
[MediatorHttpDelete("/{id:int}/archive", SuccessStatusCode = 202)]
public Task Handle(ArchiveWidget command, IMediatorContext context, CancellationToken ct) => …;

An IStreamRequest<T> becomes a Server-Sent Events response — one frame per item, serialized through your JsonSerializerContext:

[MediatorHttpGet("/watch", EventName = "widget")]
public async IAsyncEnumerable<Widget> Handle(
WatchWidgets request,
IMediatorContext context,
[EnumeratorCancellation] CancellationToken ct
) { … }

SSE rather than a JSON array because a stream is open-ended: the caller wants each item as it arrives, and an array cannot be read until it closes. EventName sets the SSE event: field; leave it off and frames carry data only, which is what a browser’s default onmessage handler reads. A stream request must be a GET — anything else is SWM007.

Everything the typed endpoint generator can attach, these attributes can attach — set on the group, overridden per endpoint.

[MediatorHttpGroup(
"/api/widgets",
RequiresAuthorization = true,
AuthorizationPolicies = ["widgets:read"],
RateLimitingPolicy = "api",
Tags = ["Widgets"]
)]
public class WidgetHandlers : …
{
[MediatorHttpGet("/health", AllowAnonymous = true, ExcludeFromDescription = true)]
public Task<Health> Handle(GetHealth request, IMediatorContext context, CancellationToken ct) => …;
}
Authorization RequiresAuthorization, AuthorizationPolicies, Roles, AllowAnonymous
CORS CorsPolicy, DisableCors
Rate limiting RateLimitingPolicy, DisableRateLimiting
IP filtering IpFilterPolicy, AllowAnyIp — no ASP.NET counterpart; it is one of the things this server does itself
OpenAPI OperationId, Summary, Description, Tags, ExcludeFromDescription

There is no CachePolicy, because this server has no output caching to point it at.

Authorization is emitted as endpoint metadata, not as a check inside the handler — so a denied request never reaches the mediator, and the handler’s dependencies are never constructed. An endpoint asking for authorization also beats a group that said AllowAnonymous, which is the direction that fails safe.

The attributes only see what is declared in source. For a route decided at runtime, or a contract from an assembly you do not own, the same calls are available directly:

app.MapGroup("/api", api =>
{
api.MapMediatorPost<CreateWidget, Widget>("/widgets");
api.MapMediatorGet<GetWidget, Widget>("/widgets/{id}", ctx =>
new GetWidget(int.Parse(ctx.Request.RouteValues["id"]!)));
});

Body verbs need nothing from you — the contract comes out of the JSON body using the registered metadata. GET and DELETE take a bind delegate, because turning a query string into a contract without reflection means somebody has to write the assignment. That somebody is normally the generator.

Everything the generator can refuse to do, it refuses at build time. The SWM prefix keeps these separate from the endpoint generator’s SWS codes, so you can suppress one family without silencing the other.

Code Meaning
SWM001 Invalid route template
SWM002 The attribute is not on a mediator handler
SWM003 A contract member cannot be bound from the route or query string
SWM004 The contract has no public constructor the generator can call
SWM005 Duplicate route in the assembly
SWM006 No [JsonSerializable] metadata for a contract or result (warning)
SWM007 A stream request published on something other than GET
SWM008 A route token cannot be applied to a body-bound contract (warning)
SWM009 A route token nothing binds (warning)
SWM010 The handler is not reachable from generated code

Same rule as everywhere else in this server: what crosses the wire needs compiled metadata.

[JsonSerializable(typeof(Widget))]
[JsonSerializable(typeof(CreateWidget))]
public partial class ApiJson : JsonSerializerContext;

The generator registers every context it finds in the assembly and warns (SWM006) about a contract or result that no context covers. Registration is idempotent, so a project using both this and the endpoint generator registers each context once.

A mediator handler is often the inside of your application — it was written assuming the caller had already been checked. Publishing it over HTTP moves it to the edge. Put authentication in front of it, and prefer declaring RequiresAuthorization on the group so a new endpoint is protected by default rather than by remembering.