Shiny.Mediator
dotnet add package Shiny.Net.HttpServer.MediatorIf 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 assemblyMapGeneratedMediatorEndpoints() registers the lot. Each handler also gets its own
Map{HandlerName}MediatorEndpoints() if you want to mount them separately.
How a contract gets filled in
Section titled “How a contract gets filled in”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.
Commands and streams
Section titled “Commands and streams”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.
Policies and metadata
Section titled “Policies and metadata”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.
Binding it by hand
Section titled “Binding it by hand”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.
Two things that will bite you
Section titled “Two things that will bite you”Build-time diagnostics
Section titled “Build-time diagnostics”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 |
JSON metadata
Section titled “JSON metadata”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.
Before you expose one
Section titled “Before you expose one”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.


