v9: AsyncAPI 3.0 document generation - #2280
Conversation
Adds a new JustSaying.AsyncApi package that generates an AsyncAPI 3.0 document describing the bus's publications and subscriptions, using ByteBard.AsyncAPI.NET for the document model and JsonSchemaExporter for payload schemas. Core gains a metadata registry seam: the fluent builders record each publication and subscription (message CLR type, destination kind and resolved name, direction, subscription group, wire name) into an IMessagingMetadataRegistry when one is registered, at configure time and before any AWS infrastructure is provisioned. The registry is opt-in via the existing IServiceResolver parameter, so nothing changes for apps that don't use it. Mapping: each publication becomes a channel plus a send operation, each subscription a channel plus a receive operation; multi-type queues become one channel with a messages map; CloudEvents registrations name messages by their CloudEvents type. Dynamic (per-message) destinations have no static address and are skipped. The IAsyncApiDocumentProvider service has a stable full name so a future GetDocument.Insider-style build-time tool can resolve it by name from a captured host. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Introduces first-party AsyncAPI 3.0 generation using messaging metadata captured during bus configuration. This PR builds on the v9 CloudEvents and multi-type queue work from #2184.
Changes:
- Adds publication/subscription metadata capture across fluent builders.
- Adds the
JustSaying.AsyncApipackage, schema generation, and document provider. - Adds CloudEvents, multi-type queue, and metadata registry tests.
Reviewed changes
Copilot reviewed 34 out of 35 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
tests/JustSaying.AsyncApi.Tests/WhenGeneratingWithCloudEventsAndMultiTypeQueues.cs |
Tests CloudEvents and multi-type queues. |
tests/JustSaying.AsyncApi.Tests/WhenGeneratingAnAsyncApiDocument.cs |
Tests core document generation. |
tests/JustSaying.AsyncApi.Tests/MessagingMetadataRegistryTests.cs |
Tests metadata deduplication and region capture. |
tests/JustSaying.AsyncApi.Tests/JustSaying.AsyncApi.Tests.csproj |
Defines the AsyncAPI test project. |
src/JustSaying/PublicAPI/PublicAPI.Unshipped.txt |
Records new core public APIs. |
src/JustSaying/Messaging/Metadata/SubscriptionMetadata.cs |
Models subscription metadata. |
src/JustSaying/Messaging/Metadata/PublicationMetadata.cs |
Models publication metadata. |
src/JustSaying/Messaging/Metadata/MessagingMetadataRegistry.cs |
Stores and deduplicates metadata. |
src/JustSaying/Messaging/Metadata/MessagingDestinationKind.cs |
Defines destination types. |
src/JustSaying/Messaging/Metadata/MessageTypeMetadata.cs |
Models message type metadata. |
src/JustSaying/Messaging/Metadata/IMessagingMetadataRegistry.cs |
Defines the metadata registry contract. |
src/JustSaying/Messaging/MessageSerialization/SystemTextJsonSerializationFactory.cs |
Exposes serializer options. |
src/JustSaying/Fluent/TopicSubscriptionBuilder\1.cs` |
Captures topic subscription metadata. |
src/JustSaying/Fluent/TopicPublicationBuilder\1.cs` |
Captures topic publication metadata. |
src/JustSaying/Fluent/TopicAddressPublicationBuilder\1.cs` |
Captures addressed-topic metadata. |
src/JustSaying/Fluent/QueueSubscriptionBuilder\1.cs` |
Captures queue subscription metadata. |
src/JustSaying/Fluent/QueuePublicationBuilder\1.cs` |
Captures queue publication metadata. |
src/JustSaying/Fluent/QueueAddressSubscriptionBuilder\1.cs` |
Captures addressed-queue subscriptions. |
src/JustSaying/Fluent/QueueAddressPublicationBuilder\1.cs` |
Captures addressed-queue publications. |
src/JustSaying/Fluent/MultiTypeQueueSubscriptionBuilder.cs |
Captures multi-type queue metadata. |
src/JustSaying.CloudEvents/PublicAPI/PublicAPI.Unshipped.txt |
Records exposed CloudEvents APIs. |
src/JustSaying.CloudEvents/CloudEventsServiceCollectionExtensions.cs |
Registers CloudEvents options. |
src/JustSaying.CloudEvents/CloudEventSerializationFactory.cs |
Exposes the payload serializer factory. |
src/JustSaying.CloudEvents/CloudEventOptions.cs |
Exposes CloudEvents type lookup. |
src/JustSaying.AsyncApi/PublicAPI/net8.0/PublicAPI.Unshipped.txt |
Defines the new package API surface. |
src/JustSaying.AsyncApi/PublicAPI/net8.0/PublicAPI.Shipped.txt |
Establishes the shipped API baseline. |
src/JustSaying.AsyncApi/JustSaying.AsyncApi.csproj |
Defines the shipping package. |
src/JustSaying.AsyncApi/JsonSchemaNodeMapper.cs |
Maps exported JSON schemas. |
src/JustSaying.AsyncApi/IAsyncApiDocumentProvider.cs |
Defines document generation tooling API. |
src/JustSaying.AsyncApi/AsyncApiServiceCollectionExtensions.cs |
Registers AsyncAPI services. |
src/JustSaying.AsyncApi/AsyncApiOptions.cs |
Defines document options. |
src/JustSaying.AsyncApi/AsyncApiDocumentProvider.cs |
Builds and serializes documents. |
src/JustSaying.AsyncApi/AsyncApiDocumentGenerator.cs |
Generates AsyncAPI channels and operations. |
JustSaying.slnx |
Adds the package and tests to the solution. |
Directory.Packages.props |
Adds AsyncAPI and JSON dependencies. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…subscription The address-based builders (topic ARN, queue ARN/URL) previously called SetRegion with the destination's region, so whichever destination was configured first silently became the document-wide region and every other destination was documented against it. The registry's region is now only set from the bus's configured region, and explicitly-addressed destinations carry their own region on the metadata entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Channels are keyed by destination identity (address, kind, region), so a topic and queue sharing a name, or the same name in two regions, no longer collapse into one channel with the wrong server reference. - Send/receive operations merge the message types of every registration that targets the same destination instead of the last one overwriting the rest. - Servers are generated per region, and destinations addressed in another region reference a region-suffixed server rather than the bus default. - Recursive schemas: the exporter's local $ref pointers are resolved and inlined (cycles emit an empty schema) instead of being dropped. - No payload schema is inferred for a Newtonsoft or custom serialization factory, since System.Text.Json-shaped schemas can misdocument the wire contract; an explicit AsyncApiOptions.SerializerOptions still applies. - The document provider observes its cancellation token when writing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 37 out of 38 changed files in this pull request and generated 2 comments.
Suppressed comments (3)
src/JustSaying.AsyncApi/AsyncApiDocumentGenerator.cs:175
- The generated AWS hostname is incorrect outside the standard
awspartition. For example,cn-north-1requires the.amazonaws.com.cnsuffix (the existingSqsEndpointHelper.cs:5-12already handles this), but this emits.amazonaws.com; the SQS block below has the same defect. Derive both hosts from the region's partition/DNS suffix instead of hard-coding it.
document.Servers[ServerKey("sns", region, primaryRegion)] = new AsyncApiServer()
{
Host = $"sns.{region}.amazonaws.com",
Protocol = "sns",
Description = $"Amazon SNS in {region}.",
src/JustSaying/Fluent/QueueAddressSubscriptionBuilder`1.cs:152
- This metadata drops the per-subscription serializer selected by
WithMessageBodySerializer(used at line 120). The generator therefore derives a payload schema from the global serialization factory even when this queue is actually deserialized by a custom serializer with a different wire contract. Capture schema/serializer information for this registration, or suppress inferred payload schemas when an override is present.
metadataRegistry.AddSubscription(new SubscriptionMetadata(
queue.QueueName,
topicName: null,
attachedQueueConfig.SubscriptionGroupName,
attachedQueueConfig.RawMessageDelivery,
[new MessageTypeMetadata(typeof(T), bus.MessageTypeRegistry.GetLogicalName(typeof(T)))],
_queueAddress.RegionName));
src/JustSaying/Fluent/QueueAddressPublicationBuilder`1.cs:157
QueueAddressQueueaccepts queue URLs with a trailing slash by ignoring empty path segments, but this last-segment lookup returns an empty destination name for the same valid URL. That produces an empty channel key/address in the AsyncAPI document. Select the last non-empty trimmed segment instead.
metadataRegistry.AddPublication(new PublicationMetadata(
MessagingDestinationKind.SqsQueue,
_queueAddress.QueueUrl.Segments[_queueAddress.QueueUrl.Segments.Length - 1].TrimEnd('/'),
isDynamic: false,
[new MessageTypeMetadata(typeof(T), subject)],
_queueAddress.RegionName));
Two AsyncAPI object keys could still collide silently. Message keys were derived from the sanitized wire name each time they were needed, so two distinct wire names that sanitize to the same key (orders/placed and orders_placed) overwrote each other in the channel's messages map, and the operation merge treated them as one message. Each channel now allocates a key per wire name and operation references read that allocation, so a reference always resolves to the message the channel holds. The merge dedupes on the wire name rather than the key. The channel key fallback chain stopped after appending the region, and that last candidate was used without checking whether it belonged to another destination, so a queue genuinely named orders-queue-us-east-1 could have its channel taken over by a cross-region orders queue. Both fallbacks now run through a shared allocator that keeps appending a deterministic numeric suffix until the key is free or already owned by the same destination identity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Defer AWS credential resolution to request time in DefaultAwsClientFactory, so building the bus (and generating a document) works without resolvable credentials, such as on CI - Root ByteBard's reflectively-created enum arrays so serialization works under Native AOT - Take an optional ILogger and warn when parts of the document are omitted (dynamic publications, underivable payload schemas) or the document is empty - Document the CloudEvents 1.0 envelope as the payload with the data schema nested under 'data', and describe raw vs SNS-enveloped delivery per subscription - Expand closed generic type names in titles and summaries - Rethrow bus-build failures from the document provider with an error that explains handlers must be registered even for documentation-only entry points - Correct the spec version wording to AsyncAPI 3.1 and pin it with a test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ild time Referencing the package makes dotnet build write asyncapi.json next to the project, mirroring ASP.NET Core's build-time OpenAPI generation. The MSBuild target runs a GetDocument tool via dotnet exec with the application's deps.json and runtimeconfig.json; the tool captures the application's host with HostFactoryResolver (aborted at Build(), so the host never starts and no AWS calls are made) and resolves IAsyncApiDocumentProvider by name via reflection, so the tool and runtime package versions are decoupled. - Configurable via JustSayingAsyncApiGenerateDocumentsOnBuild (default true) and JustSayingAsyncApiDocumentsDirectory (default: the project directory) - Incremental: skipped when the app hasn't rebuilt, and the document is only rewritten when its content changes, keeping timestamps stable - Generation failures surface as first-class build errors in canonical MSBuild format, without the MSB3073 command-line dump - Integration tests spawn the tool against a fixture app exactly as the targets do, covering the happy path, no-rewrite behaviour, and the missing-handler and missing-registration failure modes - build.ps1 now packs JustSaying.AsyncApi and JustSaying.AsyncApi.BuildTools and runs JustSaying.AsyncApi.Tests, which were missing from CI Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
067985f to
12ec1f3
Compare
Exercising JustSaying.AsyncApi.BuildTools across a wider scenario matrix (web apps, legacy hosts, CLI-style entry points, multi-targeting, test projects, shared documentation directories) surfaced four rough edges, fixed here after comparing with how Microsoft.Extensions.ApiDescription.Server handles the same problems: - Skip test projects: modern test frameworks make test projects executables, and one referencing this package (e.g. via Directory.Build.props) would run its whole test suite inside the build before failing. The after-build hooks now exclude IsTestProject. - Cap the entry point wait: an application that blocks before building its host stalled the build for HostFactoryResolver's five-minute default with no output. A new JustSayingAsyncApiEntryPointTimeoutSeconds property (default 60) flows to the tool, and the timeout error names the property and the escape hatch (the entry assembly is 'JustSaying.AsyncApi.GetDocument' during generation). - Make the file name configurable: two projects pointing JustSayingAsyncApiDocumentsDirectory at the same folder silently overwrote each other's asyncapi.json. A new JustSayingAsyncApiDocumentFileName property (default 'asyncapi') maps to a --file-name tool argument with ApiDescription.Server's naming scheme; shared directories can use $(MSBuildProjectName). - Generate once for multi-targeted projects: each inner build previously ran the tool against the same output file. New buildMultiTargeting targets mirror ApiDescription.Server: the outer build generates for the first .NET 8+ framework and the inner-build hook is disabled. The generation target is also split into an invocable GenerateJustSayingAsyncApiDocuments target plus a private after-build hook, so it can be run manually when on-build generation is disabled, and the host-build failure error now mentions that the entry point is invoked with ['--applicationName', '<assembly name>'] rather than empty arguments, which otherwise makes CLI-style entry points fail confusingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 55 out of 56 changed files in this pull request and generated 1 comment.
Suppressed comments (6)
src/JustSaying/Messaging/Metadata/PublicationMetadata.cs:25
- SQS publications are not always the bare serializer payload represented by this metadata. Both queue publication builders wrap non-raw messages as
{ "Message": "<serialized JSON>", "Subject": "..." }(OutboundMessageConverter.cs:51-57), whileWithRawMessages()and self-describing serializers do not. BecausePublicationMetadatarecords no wire mode, the generator always emits the CLR payload schema and therefore misdocuments every default SQS publication. Record the effective raw/envelope mode here, populate it from both queue builders, and generate the corresponding envelope schema.
src/JustSaying.AsyncApi.BuildTools/build/JustSaying.AsyncApi.BuildTools.targets:49 - The tool writes generated paths into this cache, but only the cache itself is registered in
FileWrites. Consequentlydotnet cleanleaves all generated AsyncAPI documents behind. Read the cache into an item list and register those paths too.
<ItemGroup>
<FileWrites Include="$(_JustSayingAsyncApiFileListPath)" />
</ItemGroup>
build.ps1:29
JustSaying.AsyncApihas a package-producing project reference toJustSaying.CloudEvents, but the release pack loop still never packs the shipping CloudEvents project. The build will produce an AsyncApi package whose declared dependency is absent from the release artifacts. Add CloudEvents tolibraryProjectsbefore packing AsyncApi.
(Join-Path $solutionPath "src" "JustSaying.AsyncApi" "JustSaying.AsyncApi.csproj"),
src/JustSaying.AsyncApi/AsyncApiDocumentGenerator.cs:203
- This hard-coded DNS suffix produces an invalid SNS host for China regions (for example
cn-north-1requiresamazonaws.com.cn). Resolve the service endpoint from AWS region metadata instead of assuming the commercial partition suffix.
Host = $"sns.{region}.amazonaws.com",
src/JustSaying.AsyncApi/AsyncApiDocumentGenerator.cs:213
- This assumes every queue is hosted at the standard AWS endpoint.
QueueAddress.FromUrlexplicitly supports custom URLs such as LocalStack; those registrations currently becomesqs.unknown.amazonaws.com, and China regions also require a different DNS suffix. Preserve the explicit endpoint in metadata and otherwise resolve the regional SQS endpoint from AWS metadata.
Host = $"sqs.{region}.amazonaws.com",
src/JustSaying.AsyncApi/AsyncApiDocumentGenerator.cs:14
- The PR title and description promise AsyncAPI 3.0 generation, but this generator is documented and tested as emitting 3.1.0. Either emit the advertised 3.0 document or update the PR/package-facing description to 3.1 so consumers select the correct validator and tooling.
/// Generates an AsyncAPI 3.1 document from the publications and subscriptions captured in an
/// <see cref="IMessagingMetadataRegistry"/>.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An outer build invoked with a runtime identifier only produces RID-specific outputs, so the inner generation invocation must evaluate the same global properties to find the assembly, deps file, and runtimeconfig the build just wrote. RemoveProperties="RuntimeIdentifier" (copied from Microsoft.Extensions.ApiDescription.Server) pointed generation at non-RID paths that were never built, failing any clean 'dotnet build -r <rid>' of a multi-targeted project. Reproducing that also surfaced a second bug in the same target: the batched-PropertyGroup guard intended to pick the first supported target framework actually kept the last one (net10.0 for 'net8.0;net10.0'). The supported frameworks are now collected into an item and the first is taken deterministically. Verified against the packed nupkg with the MultiTargetWorker scenario: clean 'dotnet build -r osx-arm64 --self-contained false' now generates from the RID-specific net8.0 outputs, and plain/incremental builds are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 55 out of 56 changed files in this pull request and generated 4 comments.
Suppressed comments (6)
src/JustSaying.AsyncApi/AsyncApiDocumentGenerator.cs:213
- This SQS hostname is also wrong outside the standard AWS partition; China queue URLs use the
amazonaws.com.cnsuffix, and explicit queue URLs may use another host entirely. Capture/resolve the actual endpoint rather than rebuilding it assqs.{region}.amazonaws.com.
Host = $"sqs.{region}.amazonaws.com",
build.ps1:34
- The CI script only runs projects in
$testProjects;JustSaying.CloudEvents.Testsis not listed, so the CloudEvents suite in this stacked change never executes in CI. Include it alongside the new AsyncApi suite.
(Join-Path $solutionPath "tests" "JustSaying.AsyncApi.Tests" "JustSaying.AsyncApi.Tests.csproj"),
src/JustSaying.AsyncApi/AsyncApiDocumentGenerator.cs:73
- During build-time generation the process entry assembly is
JustSaying.AsyncApi.GetDocument, not the loaded application (the tool itself notes this inProgram.cs:116-117). If the user leavesTitleunset, the generated document is therefore titled after the tool instead of the application. Resolve the captured host's application name or pass the application assembly name into generation.
Title = _options.Title ?? Assembly.GetEntryAssembly()?.GetName().Name ?? "JustSaying application",
src/JustSaying.AsyncApi.BuildTools/build/JustSaying.AsyncApi.BuildTools.targets:19
- The comment documents direct invocation via
dotnet msbuild -t:GenerateJustSayingAsyncApiDocuments, but this target has no build dependency. On a clean checkout,TargetPath, the deps file, and the runtimeconfig have not been produced, so the documented command fails. Either make a direct-invocation wrapper build the selected target framework first or document that callers must build first.
<Target Name="GenerateJustSayingAsyncApiDocuments"
Inputs="$(TargetPath);$(ProjectDepsFilePath);$(ProjectRuntimeConfigFilePath)"
Outputs="$(_JustSayingAsyncApiFileListPath)">
src/JustSaying.AsyncApi/AsyncApiDocumentGenerator.cs:203
- Hard-coding
amazonaws.comproduces an invalid SNS endpoint for supported non-default AWS partitions; for examplearn:aws-cn:sns:cn-north-1:...is actually served underamazonaws.com.cn. Preserve the ARN partition/endpoint metadata or resolve the hostname through the AWS SDK instead of constructing it from only the region.
This issue also appears on line 213 of the same file.
Host = $"sns.{region}.amazonaws.com",
src/JustSaying.CloudEvents/CloudEventsServiceCollectionExtensions.cs:38
- Because this uses
TryAdd, a previously registeredCloudEventOptionsinstance may differ from the localoptionscaptured by the serialization-factory lambda below. AsyncApi resolves the registered instance while the serializer uses the local one, so the documented CloudEvents type names can differ from the emitted wire values. ResolveCloudEventOptionsfrom the service provider when constructing the factory.
services.TryAddSingleton(options);
JustSaying.AsyncApi takes a package dependency on JustSaying.CloudEvents, but CI only packs the projects listed in $libraryProjects and publishes those nupkg files, so a release would have shipped an AsyncApi package whose CloudEvents dependency did not exist on NuGet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Publishing to an SQS queue without raw messages wraps the payload in
JustSaying's { "Message", "Subject" } envelope, so the SQS body is not the
payload the document describes. That is the default, and the publication
metadata did not carry the effective raw-message setting at all, so the
generated document silently claimed the bare payload was the wire body.
PublicationMetadata now captures whether the envelope is used, resolved from
the same combination the publisher itself uses (the builder's WithRawMessages,
the write configuration's IsRawMessage, and a self-describing serializer),
and the send operation carries a description of the envelope, mirroring how
subscriptions describe SNS raw versus enveloped delivery.
The envelope is described rather than modelled as the payload schema because
the payload survives inside it as a JSON-encoded string, and the AsyncAPI
schema model has no contentSchema keyword to nest the real schema under; the
message schema stays the useful one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GenerateAsync builds the bus, exports the schemas, and runs PostProcess before its first await, so invoking it on the calling thread left all of that outside the two-minute timeout: anything slow or blocked there hung the build indefinitely instead of failing with the timeout error. The invocation now happens on the task being waited on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v9 stacked PR — AsyncAPI document generation
Adds a
JustSaying.AsyncApipackage that generates an AsyncAPI 3.0 document describing an app's publications and subscriptions — the messaging equivalent of ASP.NET Core's OpenAPI document. Stacked on #2184 (which is stacked on #2183 → #2182) — review/merge those first.As far as I can tell no .NET messaging library has a first-party AsyncAPI story (MassTransit and aws-dotnet-messaging have nothing, NServiceBus has demo-only samples), so this would be a nice differentiator.
The metadata seam (core)
IMessagingMetadataRegistry— resolved via theIServiceResolvertheirConfiguremethods already take, so it's fully opt-in and costs nothing when unused.Interrogate()— that's diagnostic-only, keyed byType.Name, and only populated after start.The package
services.AddJustSayingAsyncApi(o => { o.Title = "Orders"; ... })— registers the registry and anIAsyncApiDocumentProvider.sendoperation; subscription → channel +receiveoperation; a multi-type queue (v9 (3/3): CloudEvents and multi-type-per-queue subscriptions #2184) → one channel with a messages map, which is exactly the AsyncAPI 3.0 model; CloudEvents registrations name messages by their CEtype. Dynamic (WithTopicName(Func<...>)) destinations have no static address, so they're skipped.JsonSchemaExporter, driven by the app's actual serializer options — so the schema reflects the real wire contract, including the source-generated STJ path from v9 (2/3): Native AOT support #2183.ByteBard.AsyncAPI.NET(the official continuation of LEGO.AsyncAPI.NET) — core package only, zero transitive deps on net8.0.Where this is heading
The provider interface (
JustSaying.AsyncApi.IAsyncApiDocumentProvider) has a deliberately stable full name andGetDocumentNames()/GenerateAsync(name, TextWriter)shape: it mirrors how ASP.NET Core's build-time OpenAPI tooling resolvesIDocumentProviderby name from the app's captured host. I've spiked that mechanism (HostFactoryResolver+stopApplication: true— hosted services never start, sobus.StartAsync/AWS never runs) and it works; the follow-up PR is the.targets+ insider tool packaging fordotnet build-time generation.Outstanding actions:
MapAsyncApi()ASP.NET Core endpointdataschema)10 new tests + 303/304 unit (one pre-existing flaky channels test) + 10 CloudEvents green; solution builds clean with analyzers on.
Stack
slang25/v9-cloudevents)🤖 Generated with Claude Code