Wolverine Integration
The Bielu.AspNetCore.AsyncApi.Wolverine package documents the messages
Wolverine routes, reading the routing from the running Wolverine runtime
instead of from attributes. Whatever you configure with ToKafkaTopic, ToSignalR or
ListenToKafkaTopic is what the document describes, so the two can't drift apart.
Installation
dotnet add package Bielu.AspNetCore.AsyncApi.Wolverine
Configuration
Call AddWolverine on the options of each document that should include Wolverine messages:
builder.Services.AddAsyncApi("realtime", options =>
{
options.AddServer("signalr", "localhost", "signalr");
options.AddWolverine(wolverine => wolverine
.IncludeAssemblyContaining<OrderShipped>()
.IncludeSchemes("signalr"));
});
| Option | Default | Purpose |
|---|---|---|
IncludeAssembly / IncludeAssemblyContaining<T> |
Wolverine's application assembly | Assemblies whose public, concrete types are checked for routes |
IncludeSchemes |
Every scheme except local, stub and tcp |
Which transports this document covers, e.g. "signalr" or "kafka" |
UseServer(scheme, serverName) |
The scheme itself | The AsyncAPI server channels on that transport link to (it must be registered with AddServer) |
MapChannel(endpointUri, address) |
Derived from the endpoint | Overrides a channel address |
SignalRClientMethod |
ReceiveMessage |
The client method SignalR operations target |
IncludeReplies() |
Off | Also documents a returned message as a reply when it has a route of its own; unrouted ones always are (AsyncAPI 3.x) |
How the document is built
For each candidate message type, every Wolverine route to an included transport becomes a send
operation on the channel of the route's endpoint. Every listening endpoint with a default incoming
message type (DefaultIncomingMessage<T>()) becomes a receive operation. A message published to two
transports appears once in components/messages, with an operation per transport.
Messages are named with Wolverine's message type name, which is what goes on the wire: the
message-type header on brokers, and the CloudEvents type on SignalR. Give public messages an explicit
[MessageIdentity("orders.order-shipped")] so renaming the class doesn't change the contract.
Channel addresses
- Kafka and other brokers: the topic or queue name from the endpoint URI (
kafka://topic/ordersbecomesorders) - SignalR: Wolverine's SignalR transport has a single endpoint,
signalr://wolverine/, which doesn't name the hub. The package looks up the path theWolverineHubis mapped at withMapWolverineSignalRHub, and fails with a clear message if it finds none or several; useMapChannel(new Uri("signalr://wolverine/"), "/hubs/chat")in that case
SignalR messages
Wolverine's SignalR transport delivers every message on the ReceiveMessage client method as a
CloudEvents JSON document: type is the message name and data the payload. The document's message
payloads describe data.
The transport serializes with its own JsonSerializerOptions, not ASP.NET Core's JsonOptions, while
the document's schemas are generated from JsonOptions. If you customise JSON (enum naming, number
handling), pass the same settings to the transport with
opts.UseSignalR<THub>().OverrideJson(options) so the wire matches the document.
Replies
A Wolverine handler can return a message. Every request carries a reply-uri header, and when it also
names the returned type in its reply-requested header, as InvokeAsync<Quote>(new GetQuote(sku)) does,
Wolverine sends the returned message to that address instead of publishing it. Otherwise the message is
published through the routing rules like any other.
The handler itself does not say which of the two is meant, so the package goes by the routing:
- A returned message with no route can only ever leave the handler as a reply, so it is always documented as one
- A returned message that has a route may be a reply or an ordinary published event. It is documented as a reply only when you opt in:
options.AddWolverine(wolverine => wolverine.IncludeReplies());
A receive operation with a reply gets:
reply.address.locationset to$message.header#/reply-urireply.channelpointing at a channel of its own, named after the operation (receive.quotes.get-quote.reply) and without an address, because the address is only known from the requestreply.messageslisting the returned message types
Only listeners with a default incoming message type have a receive operation, so only their handlers
are covered. AsyncAPI 2.x has no reply object, so a 2.x document is left unchanged.
Spec metadata
Routing doesn't say everything a document can: how to correlate a request with a reply, where to read more, which traits apply, who may call. Declare those on the Wolverine options:
builder.Services.AddAsyncApi("realtime", options =>
{
options.AddMessageTrait("traced", new AsyncApiMessageTrait { ContentType = "application/json" });
options.AddOperationTrait("audited", new AsyncApiOperationTrait { Summary = "Audited operation" });
options.AddSecurityScheme("apiKey", new AsyncApiSecurityScheme { Type = SecuritySchemeType.ApiKey, In = ParameterLocation.User });
options.AddWolverine(wolverine => wolverine
.IncludeCorrelationId()
.WithExternalDocs<OrderPlaced>("https://example.com/orders", "Order events")
.WithMessageTraits<OrderPlaced>("traced")
.WithOperationTraits<OrderPlaced>("audited")
.RequireSecurity<OrderPlaced>("apiKey"));
});
| Method | Effect |
|---|---|
IncludeCorrelationId() |
Every message sent through a broker gets a correlationId at $message.header#/correlation-id, the header Wolverine stamps on each envelope it sends that way. SignalR messages are left out: that transport sends a CloudEvents JSON wrapper, not this header |
WithExternalDocs<T>(url, description) |
externalDocs on the message for T. url must be absolute, as AsyncAPI requires |
WithMessageTraits<T>(keys) |
traits references on the message for T |
WithOperationTraits<T>(keys) |
traits references on every operation of T |
RequireSecurity<T>(keys) |
security references on every operation of T |
Traits and security schemes are registered on the document's own options (AddMessageTrait,
AddOperationTrait, AddSecurityScheme), exactly as for attribute-declared documents; the Wolverine
methods only reference them by key.
[Message] on a message class
A Wolverine message class can carry [Message] (the class is its own payload, so the type argument is
optional) and the integration applies it to the message it builds:
[MessageIdentity("billing.invoice-issued")]
[Message(
Title = "Invoice issued",
Summary = "Raised when an invoice is issued.",
CorrelationIdLocation = "$message.payload#/invoiceId",
ExternalDocsUrl = "https://example.com/invoices",
Traits = ["traced"])]
public sealed record InvoiceIssued(Guid InvoiceId);
Supported: Name, Title, Summary, Description, ContentType, HeadersType, BindingsRef,
CorrelationIdLocation/CorrelationIdDescription, ExternalDocsUrl/ExternalDocsDescription,
Traits and tags. Precedence, highest first:
- Calls on the Wolverine options.
WithExternalDocs<T>replaces the attribute'sexternalDocs;WithMessageTraits<T>adds to the attribute's traits (a trait named by both is referenced once). - The
[Message]on the class. - XML documentation and Wolverine's defaults.
IncludeCorrelationId() only fills in messages that have no correlationId yet, so an attribute-declared
one is kept. The component key, and the channel and operation ids derived from it, always stay Wolverine's
message type name ([MessageIdentity]), since that is what is on the wire. Name changes only the
displayed name; MessageId is ignored.
Combining with attributes
AddWolverine runs as a document transformer, after attribute discovery. Integrations that don't go
through Wolverine can keep using [AsyncApi], [Channel] and the operation attributes; both end up in
the same document, and channels with the same address are merged.