Skip to content

Support parameterized AsyncAPI consumer channels - #914

Closed
Levyks wants to merge 1 commit into
corvus-dotnet:mainfrom
Levyks:feat/asyncapi-parameterized-consumers
Closed

Support parameterized AsyncAPI consumer channels#914
Levyks wants to merge 1 commit into
corvus-dotnet:mainfrom
Levyks:feat/asyncapi-parameterized-consumers

Conversation

@Levyks

@Levyks Levyks commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Generate consumer StartAsync parameters for templated AsyncAPI channel addresses.
  • Expand channel parameters before subscribing.
  • Reuse the concrete subject for unsubscribe and dead-letter handling.
  • Preserve existing dynamic-address consumer behavior.
  • Add generation and compilation tests.

For example, a channel address such as orders.{orderId}.created now generates:

StartAsync(string orderId, CancellationToken cancellationToken = default)

Testing

  • dotnet test tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests/Corvus.Text.Json.AsyncApi.CodeGeneration.Tests.csproj --no-restore --framework net10.0
  • 173 tests passed.

foreach (ChannelParameter parameter in op.Parameters)
{
string parameterName = ToCamelCase(parameter.Name);
w.WriteLine($"channel = channel.Replace(\"{{{EscapeString(parameter.Name)}}}\", {parameterName}, StringComparison.Ordinal);");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will switch this out for an alloc free version.

@mwadams

mwadams commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Thanks very much for this PR - excellent work.

I am going to sweep it into a PR to produce V5.3 which also cherry picks a number of OpenApi and AsyncApi changes from our Arazzo workflow work.

When I do that, I will make a change to rework the dynamic channel building code into a zero-allocation form.

mwadams added a commit that referenced this pull request Aug 5, 2026
* Pass binding context to generated AsyncAPI consumers

* Support parameterized AsyncAPI consumer channels

* Give a document its workspace, and let a builder hand one over (#803)

The generation work that follows needs two things the document primitives did
not offer. A document has to be able to say which workspace created it, so a
caller can hand a value to something that owns a lifetime rather than guessing
whether one is shared. And a builder has to be able to hand its rows to another
document without a serialize-and-reparse round trip.

JsonDocumentBuilder now implements IWorkspaceCreatedDocument, and the document
interface gains the two operations that make the handover possible: reading a
local element's contiguous UTF-8 without materialising it, and appending a
local element's rows into another metadata database rebased to their new
location. Both are additive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq

* Generate OpenAPI clients and servers that describe themselves (#803)

Four changes to what the OpenAPI generators emit.

A generated client can now take a context-threaded request body. The server
result factory has long offered Ok<TContext>(Source<TContext>, workspace), so a
caller assembles a body lazily with its context threaded through and it
materialises in one pass with no per-item closure. A client had no counterpart,
so anyone with a collection to put in a REQUEST body had to close over it. The
machinery was already there: the generators take the set of body pointers whose
type is an object or array, and emit the generic overload only for those. The
server command computed that set and the client command never did, so the
client path silently opted out under what its own doc comment called the
conservative default. OpenAPI 2.0 was worse and is worth naming: the parameter
did not exist there at all, so a 2.0 SERVER was also missing the closure-free
response factories every 3.x server has had.

A binary response now carries its body through the result factory, which is a
breaking change. The old parameterless Ok() could not express a body at all;
the shipped example recipe said so in a comment and returned Ok() anyway. It
now takes the bytes or a writer, and the content type the handler chooses.

An optional request body is optional. A body not marked required generated a
mandatory parameter, so a caller had to supply something for a body the
specification says may be absent.

Descriptions in the source document become XML doc comments on the generated
members, escaped so a description containing markup does not break the build.
The schema classifier takes the document root alongside the schema so it can
follow a reference rather than classifying the reference itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq

* Give AsyncAPI a responder, and its request a workspace (#803)

Request/reply had only one half. A caller could send a request and await the
correlated reply, but nothing in the transport surface let a service BE the
responder: subscribe to a channel, handle each request, and publish the reply
on the correlated reply channel. SubscribeReplyAsync is that half. It ships
with a default implementation that throws NotSupportedException, so a transport
that does not support responders is unaffected and an existing custom transport
still compiles.

RequestAsync now takes the JsonWorkspace that owns the reply's lifetime, which
is a breaking change: it is a required parameter ahead of the optional headers
and cancellation token, so every call site and every custom implementation of
the abstract overload needs it. The reply was previously materialised against a
lifetime the caller could not control, which is the wrong shape for a caller
folding the reply into a document it owns.

The generated methods for a parameterised channel address now take a span or
UTF-8 memory as well as a string, so an address composed from bytes does not
have to become a string on the way to the transport. The producer's byte
overload rents a pooled buffer, because the send outlives the call that
started it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq

* Add Corvus.Text.Json.OpenApi.Polly (#803)

AsyncAPI has had a Polly package since its transports did: a resilience
pipeline wrapped around the transport, so a deployment configures retry and
circuit-breaking without every call site knowing about it. OpenAPI had the same
need and no counterpart, so a caller wanting a resilient transport wrapped
IApiTransport by hand.

ResilientApiTransport is that counterpart, decorating an IApiTransport with a
Polly pipeline and passing every operation through unchanged otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq

* Regenerate the example recipes against the updated generators (#803)

Mechanical, with one exception. The recipes carry committed generator output,
so a generator change leaves them stale until they are regenerated.

The exception is the advanced-server recipe, whose download handler returned
the parameterless Ok() a binary response used to generate, with a comment
saying the streaming was handled elsewhere and a discarded local standing in
for the photo it could not send. The factory now takes the body, so the recipe
sends the photo and its content type, which is what it was describing all
along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq

* Document the release, and cut it as 5.3.0 (#803)

The prose that goes with the preceding commits: the AsyncAPI guide gains the
responder and the workspace-carrying request, the OpenAPI guide gains the
context-threaded request body, and two new documents cover consuming generated
types and the performance techniques the generators now rely on.

The agent guidance moves with it. Three new skills (context threading, the
bytes-to-bytes discipline, typed model construction) and two updated ones
describe the conventions these generators emit against, which is exactly the
guidance a contributor needs to extend them without reintroducing the closures
and round trips this work removed.

5.3.0 rather than 5.2.14 because two changes are breaking: RequestAsync takes a
workspace, and a binary response carries its body through the result factory.
This repository has shipped breaking changes in a patch before (5.2.7 renamed a
generated member for schemas with a property called "create"), but that one
reached almost nobody and only on regeneration. These reach every caller of
AsyncAPI request/reply and every handler returning a binary response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq

* Regenerate the recipes, and credit the contributions (#803)

The AsyncAPI consumer changes reach the committed recipe output, and the
streetlights recipes show why the parameterised-channel contribution matters:
they subscribed to

    smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured

literally, placeholder and all, so the generated consumer listened on a channel
no publisher ever wrote to. They now take the streetlight id and compose the
address from it.

VERSIONHISTORY credits both contributions to Levy Barbosa, and records that the
allocation-free composition was added when #914 was merged rather than being
part of what was contributed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq

* Update the AsyncAPI runtime tests for parameterised channels (#803)

The parameterised-channel work broke twenty-one call sites in the AsyncAPI
runtime tests, and that is the feature rather than a regression. Those tests
were asserting the bug: they delivered to

    smartylighting.streetlights.1.0.action.{streetlightId}.lighting.measured

with the placeholder still in it, because that is the address the generated
consumer really subscribed to. They now pass a streetlight id and deliver to
the address it composes.

The release notes move that change from New features to Breaking changes. It is
both, but the half a consumer has to act on is the compile break: StartAsync()
compiled before and does not now. Twenty-one call sites in one test project is
a fair preview of what a downstream consumer meets.

They also record that this reaches AsyncAPI 2.6, not only 3.0. The 2.6
generator holds a 3.0 generator and delegates emission to it, so both
contributions applied to 2.6 the moment they applied to 3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq

* Document the capabilities this release adds (#803)

Three gaps, found by asking what a reader would have to reverse-engineer from
the release notes.

docs/OpenApi.md had not been touched at all, so the headline client feature and
a breaking change were both undocumented. It now covers building a request body
without closures (the client counterpart of the server's Ok<TContext>), that an
optional body is optional, and that a binary response carries its body through
the result factory.

docs/AsyncApi.md documented channel parameters for producers only, which was
correct until now: consumers ignored them and subscribed to the template
literally. The section now covers the consumer side, including the span
overload, and says plainly what the old behaviour was, since anyone upgrading
meets the compile break first and deserves to know it was hiding a bug.

Corvus.Text.Json.OpenApi.Polly shipped with no documentation whatsoever. It now
has the section its AsyncAPI counterpart has had, with the caveat that a
retry pipeline cannot tell an idempotent operation from one that is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq

---------

Co-authored-by: Levy Barbosa <marcio.levy20@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@mwadams mwadams closed this Aug 5, 2026
mwadams added a commit that referenced this pull request Aug 5, 2026
Main now carries the OpenAPI and AsyncAPI generation work that was lifted off
this branch and released as 5.3.0, together with Levy Barbosa's two
contributions merged on top of it. This brings it back, so the campaign
continues on the released shape rather than on the shape it was ported from.

Where both sides had touched the same file, main wins on the generators: its
AsyncApi30CodeGenerator, IMessageTransport, and the advanced-server recipe are
this branch's own work plus #913, #914, and the allocation-free composition
added when they were merged. The fifty-one lines only this branch had were all
the forms main replaced.

This branch keeps what is its own: the Arazzo projects in the solution, and the
buffer-and-pooling skill's continuation-token example, which names an Arazzo
type that exists here and did not exist in the release. The AsyncAPI guide takes
main's new consumer documentation and restores the sentence pointing at the
Arazzo request/reply step.

Derived artefacts were regenerated rather than resolved: both sides had
regenerated the recipes and the sample catalog from different generator states,
so picking a side would have committed one of them stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wwPnkvEn24dgt5T8mHGJq
mwadams added a commit that referenced this pull request Aug 5, 2026
…920)

* Fix AsyncAPI recipe call sites broken by the 5.3.0 parameterised-channel change

The 5.3.0 release regenerated the ExampleRecipes Generated folders with the
parameterised-channel breaking change from #914, but left recipes 037, 038,
and 039 calling the removed parameterless StartAsync() overload, so
ExampleRecipes.slnx did not build. The call sites now supply the channel
parameter, the simulated broker publishes target the concrete channel the
consumer subscribes to, and the README samples match the code again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157Tdszg6wzgWeKgGYRBNKV

* Fix #917 and #918: escape link descriptions and align generated doc comments with signatures

A response links entry's description was the last place specification text
reached a generated doc comment verbatim; a line break in it terminated the
comment and the remainder parsed as source code, the failure mode #916
reported for operation summaries. The 3.0, 3.1, and 3.2 client generators now
route it through CodeEmitHelpers.EscapeXml (#917).

Generated doc comments also contradicted the signatures they document, so a
consuming project with GenerateDocumentationFile and TreatWarningsAsErrors
failed its build on CS1572/CS1573. The model templates' constructor,
conversion-operator, From<T>, and TryParseValue param tags now match, and the
OpenAPI client method docs cover the trailing validationMode and
responseValidationMode parameters in all four generators (#918).

Covspec link tests pin the flattened, escaped emission; a new
GeneratedDocumentationTests suite runs the CLI and compiles its output under
DocumentationMode.Diagnose, gating errors and the doc-mismatch warning
family. All new tests failed against the unfixed generators with exactly the
defect diagnostics before passing with the fixes. Example recipes are
regenerated; the diff is doc-comment lines and lock metadata. CS1591 is
tracked separately in #919.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157Tdszg6wzgWeKgGYRBNKV

* Regenerate benchmark C/ directories and repair the AsyncAPI benchmarks for 5.3.0

The model templates changed in the #918 fix, so every C/ directory is
regenerated; B/ is untouched. Modified-file diffs are doc-comment lines only.
The sweep also carries 5.3.0-era nested type-name truncation renames on the
large schemas (Ui5, Krakend, GeoJson, CmakePresets, and friends), because
5.3.0 changed the truncation without regenerating C/; git pairs those as
delete plus add.

Corvus.Text.Json.AsyncApi.Benchmarks was already broken on main: its
hand-written BenchmarkTransport still implemented the pre-5.3.0 RequestAsync
signature without the JsonWorkspace parameter, and its checked-in Generated
folder predated 5.3.0. The transport gains the workspace parameter (unused by
design; the reply is pre-parsed at setup), the Generated folder is
regenerated with the current asyncapi-generate, and the harness follows the
models into the .Models subnamespace. The benchmarks solution builds with no
warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157Tdszg6wzgWeKgGYRBNKV

* Gate the V4 engine's generated documentation alongside V5

The V4 engine was inspected for the same defect classes and verified clean:
its doc param tags name parameters that exist in the V4 signatures, and its
AppendParagraphs emits description text per line, HTML-encoded, so a
multi-line or markup-carrying description cannot escape the doc comment.
GeneratedDocumentationTests now runs the same five fixture schemas through
--engine V4 and compiles the output under DocumentationMode.Diagnose, so
both engines stay guarded rather than only the one that was broken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157Tdszg6wzgWeKgGYRBNKV

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants