Repository navigation
Derive channel and operation messages - #17
Conversation
…using oneOf for all messages (mlilback#1) * Add test example to demonstrate oneOf bug in ToAsyncApiMessage macro * Fix oneOf bug: extract individual variant schemas for enum messages * Add comprehensive test to verify oneOf fix for enum messages
* Add messages parameter to asyncapi_operation attribute * Update documentation for messages parameter in asyncapi_operation * Merge operation messages to channel messages field * Fix operation message references to point to channel messages
This reverts commit 6b3b04b.
Release v0.5.0
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #17 +/- ##
==========================================
+ Coverage 89.29% 89.43% +0.14%
==========================================
Files 6 6
Lines 962 1013 +51
==========================================
+ Hits 859 906 +47
- Misses 103 107 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
mlilback
left a comment
There was a problem hiding this comment.
Thanks for rebasing #3 and carrying this forward — the messages = [...] parsing in extract_operation follows the existing bracketed!/parse_terminated pattern correctly, cargo clippy --all-targets and cargo test are clean on the head, and the new tests pass.
I found one issue that I think needs fixing before merge, plus some cleanups. Details are in the inline comments; summary:
Blocking
HashSetdedup in the channel-messages codegen makes the generated AsyncAPI JSON non-deterministic across builds (lib.rs:954). This affects everyone using the crate, not just this feature.
Spec-validity gaps in the new feature
2. Types in messages = [...] aren't validated against #[asyncapi_messages(...)], so refs can point into a components section that doesn't exist (lib.rs:944, :1024).
3. Operation message refs aren't deduplicated, unlike the channel side (lib.rs:1031).
4. Refs can point at an undeclared channel (lib.rs:1032) — pre-existing, but now multiplied per message.
Unrelated leftovers from #3 — please revert/drop
5. message_integration.rs: changing UserJoin to a newtype variant drops the type const from its payload schema, so the example now publishes a schema serde's own output would fail.
6. examples/test_oneof.rs and examples/schema_inspection.rs are debugging scratch files.
7. Indentation churn inside quote! at lib.rs:514.
Follow-up, not blocking
8. Generic types in messages = [...] produce an unparsable-tokens error rather than a clear diagnostic (asyncapi_spec_attrs.rs:553).
| let message_calls: Vec<_> = operations_for_channel | ||
| .iter() | ||
| .flat_map(|op| &op.messages) | ||
| .collect::<std::collections::HashSet<_>>() // Deduplicate |
There was a problem hiding this comment.
Non-deterministic generated output. HashSet uses RandomState, so the order in which the channel_messages.insert(...) blocks are emitted varies per compilation. Verified empirically: rebuilding examples/message_integration.rs six times produced two different channel-message orderings (Status, Error, UserJoin, ... vs UserJoin, ChatMessage, ..., Status, Error).
Since Channel.messages is an IndexMap serialized in insertion order, the emitted AsyncAPI JSON changes between builds — which breaks reproducible builds, golden-file/spec diffs, and any checked-in generated spec.
Suggest an order-preserving dedup (a Vec plus a seen-set keyed on quote!(#path).to_string()), or dropping the dedup entirely since the IndexMap already dedups by message name.
| .filter(|op| op.channel == channel_name_str) | ||
| .collect(); | ||
|
|
||
| let messages_field = if operations_for_channel.is_empty() |
There was a problem hiding this comment.
No validation that messages = [...] types are also declared in #[asyncapi_messages(...)].
Components messages are generated only from spec_meta.message_types (line 1129). If a type appears in an operation's messages list but not in #[asyncapi_messages(...)], the channel emits "$ref": "#/components/messages/X" and the operation emits "$ref": "#/channels/c/messages/X" while components is None.
Verified: a struct with only #[asyncapi_operation(..., messages = [Msg])] produces refs into a components section that doesn't exist. The result is a silently invalid spec with no compile-time or runtime diagnostic. A compile error would be warranted here — or auto-inclusion of these types into message_types.
There was a problem hiding this comment.
I modified the components generation code and included all operation messages. Hence messages declared in operations always land in the components. This would make using asyncapi_operation(...) optional.
What do you think about that?
| }; | ||
|
|
||
| // Generate messages references if any messages are specified | ||
| let messages_field = if operation.messages.is_empty() { |
There was a problem hiding this comment.
Same issue as on the channel side (see the comment on line 944): the types listed here are never checked against #[asyncapi_messages(...)], so this can emit #/channels/{c}/messages/X refs for a message that never makes it into components.
| quote! { | ||
| // Call asyncapi_message_names() for this type and add references to channel messages | ||
| for msg_name in #type_name::asyncapi_message_names() { | ||
| message_refs.push(asyncapi_rust::MessageRef::Reference { |
There was a problem hiding this comment.
Operation message refs are not deduplicated. operation.messages.iter() pushes refs unconditionally, so messages = [Msg, Msg] — or two types whose asyncapi_message_names() overlap — yields duplicate entries in the operation's messages array. Verified output contained #/channels/.../messages/A and .../B twice each.
Duplicate message references in an operation aren't valid AsyncAPI. Note the channel side is deduped (line 954), so the two sides currently disagree.
| // Call asyncapi_message_names() for this type and add references to channel messages | ||
| for msg_name in #type_name::asyncapi_message_names() { | ||
| message_refs.push(asyncapi_rust::MessageRef::Reference { | ||
| reference: format!("#/channels/{}/messages/{}", #channel_ref, msg_name), |
There was a problem hiding this comment.
channel_ref is used verbatim with no check that the channel exists. With channel = "nonexistent" the macro happily emits #/channels/nonexistent/messages/A.
The dangling #/channels/X ref is pre-existing, but this PR multiplies it into one dangling ref per message. Since this feature's whole contract is "operations reference channel messages", validating that operation.channel matches a declared #[asyncapi_channel(name = ...)] becomes worth doing.
| let schema = schema_for!(Self); | ||
| let schema_json = serde_json::to_value(&schema) | ||
| .expect("Failed to serialize schema"); | ||
| let schema = schema_for!(Self); |
There was a problem hiding this comment.
Unrelated indentation regression: this block was de-indented by 4 spaces relative to the surrounding statements, and the redundant // Convert schemars RootSchema to JSON comment was reintroduced. cargo fmt doesn't format inside quote!, so this will persist. Could you revert this hunk?
| // Parse array of type paths: messages = [Type1, Type2, ...] | ||
| let _ = nested.value()?; // Parse the equals sign and prepare for value parsing | ||
| let content; | ||
| syn::bracketed!(content in nested.input); |
There was a problem hiding this comment.
Minor: generic message types produce a confusing error. Parsing as Path accepts Foo<Bar> only in expression-ambiguous form, and the emitted #type_name::asyncapi_message_names() would need a turbofish anyway.
Verified that messages = [Option<Msg>] fails with "comparison operators cannot be chained" plus "proc-macro derive produced unparsable tokens". Either reject non-generic-free paths with a clear syn::Error, or emit <#type_name>::asyncapi_message_names().
| description = "Sent when a user enters a chat room" | ||
| )] | ||
| UserJoin { username: String, room: String }, | ||
| UserJoin(UserJoin), |
There was a problem hiding this comment.
This change silently drops the discriminant from the payload schema. Running the example shows the generated payload for UserJoin is now just {"$ref": "#/components/schemas/UserJoin"} with no "type": {"const": "user.join"} property — while every other variant (ChatMessage, UserLeave, Status, Error) still carries its const tag.
For an internally-tagged enum the wire payload does include "type":"user.join", so the example now publishes a schema that doesn't match what serde emits; a consumer validating against it would reject valid messages.
This looks like leftover debugging from the original #3 and is unrelated to the feature — please revert it. Separately, the newtype-variant case is a real gap in derive_to_asyncapi_message's tag injection, worth its own issue.
| @@ -0,0 +1,41 @@ | |||
| use schemars::{JsonSchema, schema_for}; | |||
There was a problem hiding this comment.
Leftover debugging scratch file. It doesn't demonstrate the feature under review and doesn't use asyncapi_rust at all (only schemars/serde_json) — it just dumps raw schema JSON, which the existing debug_schema.rs example already does. It would become part of the published crate's examples and need maintenance. Recommend dropping it.
| @@ -0,0 +1,25 @@ | |||
| use asyncapi_rust::{ToAsyncApiMessage, schemars::JsonSchema}; | |||
There was a problem hiding this comment.
Same as schema_inspection.rs: leftover scratch file that dumps raw schema JSON rather than demonstrating the messages = [...] feature. Recommend dropping it.
mlilback
left a comment
There was a problem hiding this comment.
Marking this as request-changes to make the status explicit — my earlier review was filed as a plain comment. No new findings here; the inline comments there still stand.
The blocker is the HashSet dedup at asyncapi-rust-codegen/src/lib.rs:954, which makes the generated AsyncAPI JSON non-deterministic across builds. Everything else is either a spec-validity gap in the new feature or an unrelated leftover from #3 that should be reverted.
I've also filed #18 for the underlying newtype-variant tag-injection gap that the message_integration.rs change exposed, so that can be handled separately from this PR — here, just reverting the example back to the struct-like variant is enough.
Happy to help with any of these if you'd like.
…tionally to define the messages in asyncapi_messages(...)
|
Thanks for your review! I think that I addressed all points except the issue with generics. |
mlilback
left a comment
There was a problem hiding this comment.
Auto-inclusion works for me as the design — thanks for taking that route.
One thing to fix before it does what you describe, though: the merge loop sits inside the if spec_meta.message_types.is_empty() { None } guard, so it never runs in exactly the case it was written for. With no #[asyncapi_messages(...)] at all, message_types is empty, we take the None branch, and the loop is dead code.
Verified on 2ce3569:
#[derive(Serialize, Deserialize, JsonSchema, ToAsyncApiMessage)]
#[serde(tag = "type")]
pub enum Msg { Echo { text: String } }
#[derive(AsyncApi)]
#[asyncapi(title = "Probe", version = "1.0.0")]
#[asyncapi_channel(name = "chat", address = "/chat")]
#[asyncapi_operation(name = "send", action = "send", channel = "chat", messages = [Msg])]
pub struct Api;produces "$ref": "#/components/messages/Echo" in the channel with no components section in the document.
The fix is to hoist the list construction above the guard and test that instead:
// Generate components with messages and hoisted shared schemas
- let components_code = if spec_meta.message_types.is_empty() {
- quote! { None }
- } else {
- let mut all_message_types = spec_meta.message_types.clone();
-
- for type_name in spec_meta
- .operations
- .iter()
- .flat_map(|operation| operation.messages.iter())
- {
- if !all_message_types.contains(type_name) {
- all_message_types.push(type_name.clone());
- }
+ let mut all_message_types = spec_meta.message_types.clone();
+
+ for type_name in spec_meta
+ .operations
+ .iter()
+ .flat_map(|operation| operation.messages.iter())
+ {
+ if !all_message_types.contains(type_name) {
+ all_message_types.push(type_name.clone());
}
+ }
+ let components_code = if all_message_types.is_empty() {
+ quote! { None }
+ } else {
let type_calls = all_message_types.iter().map(|type_name| {I applied this locally: the probe above now emits a proper components.messages.Echo, the full suite passes, and cargo clippy --all-targets is clean. When message_types is non-empty the generated output is unchanged, so it only affects the branch that was previously emitting dangling refs.
One design question while you're in here, not a blocker: with auto-inclusion, #[asyncapi_messages(...)] becomes advisory, which means a typo'd type in messages = [...] now quietly lands in components rather than failing. That's a reasonable trade for the ergonomics — just want to confirm it's the behaviour you intend.
Everything else from the review checks out on my end — the ordering fix, the operation-level dedup (messages = [Msg, Msg] now yields one ref), the unknown-channel compile error, and the example/scratch-file reverts. The generics diagnostic can stay a follow-up.
|
Thanks again! I'm happy that almost everything is fixed.
Yes that was my intention because I though if you add a message to an operation I assume that you also want to have it in the components list.
Can you apply this patch or should I do it? |
|
Thanks for confirming the intent — that reasoning makes sense to me, and it matches how the Go ahead and apply the patch yourself — it's your branch and your commit. The diff in my last comment should apply as-is; it's purely a move of the |
|
I applied the patch. It's ready for review |
mlilback
left a comment
There was a problem hiding this comment.
Verified on 3a2a592 — the hoist matches the patch exactly, and all findings from my earlier review are now resolved. Approving.
What I checked on this head:
- Components auto-inclusion. An operation with
messages = [Msg]and no#[asyncapi_messages(...)]at all now emits a fullcomponents.messagesentry per variant, each carrying its"type": {"const": ...}tag. No dangling#/components/messages/Xrefs. - Dedup.
messages = [Msg, Msg]yields one ref per message name on both the channel and the operation side. - Determinism. Six forced rebuilds of
examples/message_integration.rsproduced identical output — this was the original blocker, and it's gone. - Unknown channels.
channel = "nope"fails at compile time withOperation 'send' references unknown channel 'nope'. - Suite. 79 tests pass across the workspace;
cargo clippy --all-targetsandcargo fmt --checkare clean, and all 8 CI checks are green.
The only item left from the original review is the generics diagnostic, which I've filed as #23 so it doesn't get lost — not a blocker for this PR.
Thanks for the patience on this one; it went through a lot of rounds and came out clean. Nice work carrying #3 across the line.
`reply(...)` inside `#[asyncapi_operation(...)]` mirrors the AsyncAPI 3.0
Operation Reply Object:
reply(
channel = "answers",
address(location = "$message.header#/replyTo", description = "..."),
messages = [Answer]
)
The specification requires `reply.messages` to reference messages of the *reply*
channel, so reply message types are folded into that channel's message map and
into components — otherwise the emitted
`#/channels/{channel}/messages/{message}` references would dangle. This follows
the auto-inclusion rule established for operation messages in #17.
Three compile-time guards: a reply channel that was never declared, reply
messages with no reply channel to hang them on, and a reply with neither
`channel` nor `address` (which the specification treats as meaningless).
Adds `examples/request_reply.rs`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I took the code of paval's PR #3 and resolved the merge conflicts against main and removed the code to fix the
oneOfproblem with enum messages because it has been already fixed in 5b2adbc.