ruststream/runtime/metadata.rs
1//! Handler metadata collected by the router and consumed by `ruststream-asyncapi`.
2
3use std::{any::type_name, borrow::Cow, marker::PhantomData};
4
5#[cfg(feature = "asyncapi")]
6use crate::asyncapi::{PublishBindings, SubscriptionBindings};
7use crate::runtime::input::{DecodeWith, Materialize};
8use crate::{ConnectedBroker, PublishPolicy, RetryDeclaration, SubscriptionSource};
9
10/// What a declared outgoing message is to the registration declaring it.
11///
12/// The kinds document differently: an answer replies to the delivery being handled, a slot entry
13/// is a destination the handler body writes to, a dead-lettered delivery is the input giving up,
14/// and a retry copy is the input coming back after a delay. `build_spec` reads this to put a
15/// reply on the `receive` operation instead of a `send` operation of its own, and to tell a
16/// dead-letter channel from a business destination.
17///
18/// The variants are named apart from the mount-position markers [`Reply`](crate::runtime::Reply)
19/// and [`Slot`](crate::runtime::Slot): a position is where a policy is bound, a kind is what the
20/// document makes of the message that leaves through it.
21///
22/// # Examples
23///
24/// ```
25/// use ruststream::runtime::{OutgoingKind, OutgoingMessageMetadata};
26///
27/// let slot = OutgoingMessageMetadata::new("events.progress", "Progress");
28/// assert_eq!(slot.kind, OutgoingKind::SlotEntry);
29/// assert_eq!(slot.with_kind(OutgoingKind::Answer).kind, OutgoingKind::Answer);
30/// ```
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
32#[non_exhaustive]
33pub enum OutgoingKind {
34 /// One entry of an `Out` slot's `#[publishes(..)]` dictionary: a destination the handler
35 /// body publishes to. The default, because a bare declaration is exactly that.
36 #[default]
37 SlotEntry,
38 /// The reply of a `reply(..)` registration: the value the handler returns, answering the
39 /// delivery it was given.
40 Answer,
41 /// The destination a `dead_letter(..)` declaration names: a delivery out of attempts leaves
42 /// the service there.
43 DeadLetter,
44 /// The destination a `.to(name)` after `out_retry(policy)` names: every deferred copy of a
45 /// delivery is published there. A subscription that takes its copies back at its own address
46 /// declares nothing and gets no entry - that address is not a channel of the service.
47 RetryCopy,
48}
49
50/// One message a handler publishes, as declared for the `AsyncAPI` document.
51///
52/// The declaration is the reply of a `reply("dest")` form, one entry of an `Out` slot's
53/// `#[publishes(..)]` dictionary, or the destination of a `dead_letter(..)` declaration.
54///
55/// Constructed by generated code through [`new`](Self::new) plus the builder-style setters;
56/// consumed by `build_spec`, which renders each entry according to its [`kind`](Self::kind).
57#[derive(Debug, Clone)]
58#[non_exhaustive]
59pub struct OutgoingMessageMetadata {
60 /// The channel / subject the message is published to.
61 pub channel: Cow<'static, str>,
62 /// Type name of the published value, as captured at registration time.
63 pub message_type: &'static str,
64 /// The type's [`Message`](crate::MessageInfo) name, when it implements that trait.
65 pub message_name: Option<&'static str>,
66 /// The type's [`Message`](crate::MessageInfo) description, when it implements that trait.
67 pub message_description: Option<&'static str>,
68 /// The type's serialized JSON Schema, when available (`asyncapi` feature).
69 pub payload_schema: Option<String>,
70 /// The serialized JSON Schema of the type's `#[message(headers(..))]` contract, when it
71 /// declares one.
72 pub headers_schema: Option<String>,
73 /// The placeholder names of a templated destination (`orders.{tenant}.v1`), in the order
74 /// they appear in [`channel`](Self::channel). Empty for a fixed one; the generated document
75 /// turns them into the channel's parameters block, so the declaration and the call site
76 /// cannot drift apart.
77 pub parameters: &'static [&'static str],
78 /// True when the type rides the serialized wire
79 /// ([`Serialized`](crate::runtime::Serialized)): its bytes are its own wire format, so the
80 /// missing payload schema is by design rather than a documentation gap.
81 pub serialized: bool,
82 /// What this entry is to its registration: a slot publish, a reply, or a dead-letter
83 /// destination.
84 pub kind: OutgoingKind,
85 /// The media type the codec bound at the mount site encodes this message in
86 /// ([`Codec::CONTENT_TYPE`](crate::codec::Codec::CONTENT_TYPE)). `None` where nothing
87 /// encodes: a [`Serialized`](crate::runtime::Serialized) reply and a dead-lettered delivery
88 /// both carry bytes that are their own wire format.
89 pub content_type: Option<&'static str>,
90 /// The runtime expression naming where a reply of this entry actually goes, when the
91 /// registration answers per delivery: the publish policy's
92 /// [`reply_address_location`](crate::PublishPolicy::reply_address_location), carried only
93 /// where a transform on the reply position names the destination. The document then reports
94 /// the channel with `address: null` and puts the expression in the operation's reply.
95 pub reply_address_location: Option<&'static str>,
96 /// What the publish policy bound on this position adds to the generated document at each
97 /// level. Empty unless the broker's policy fills it in.
98 #[cfg(feature = "asyncapi")]
99 pub bindings: PublishBindings,
100}
101
102impl OutgoingMessageMetadata {
103 /// Constructs an entry for a message type published to `channel`.
104 #[must_use]
105 pub fn new(channel: impl Into<Cow<'static, str>>, message_type: &'static str) -> Self {
106 Self {
107 channel: channel.into(),
108 message_type,
109 message_name: None,
110 message_description: None,
111 payload_schema: None,
112 headers_schema: None,
113 parameters: &[],
114 serialized: false,
115 kind: OutgoingKind::SlotEntry,
116 content_type: None,
117 reply_address_location: None,
118 #[cfg(feature = "asyncapi")]
119 bindings: PublishBindings::default(),
120 }
121 }
122
123 /// Builder-style setter for what this entry is to its registration (see
124 /// [`kind`](Self::kind)).
125 #[must_use]
126 pub const fn with_kind(mut self, kind: OutgoingKind) -> Self {
127 self.kind = kind;
128 self
129 }
130
131 /// Builder-style setter for a templated destination's placeholder names.
132 #[must_use]
133 pub fn with_parameters(mut self, parameters: &'static [&'static str]) -> Self {
134 self.parameters = parameters;
135 self
136 }
137
138 /// Builder-style setter for the [`Message`](crate::MessageInfo) name.
139 #[must_use]
140 pub fn with_message_name(mut self, name: Option<&'static str>) -> Self {
141 self.message_name = name;
142 self
143 }
144
145 /// Builder-style setter for the [`Message`](crate::MessageInfo) description.
146 #[must_use]
147 pub fn with_message_description(mut self, description: Option<&'static str>) -> Self {
148 self.message_description = description;
149 self
150 }
151
152 /// Builder-style setter for the serialized payload schema.
153 #[must_use]
154 pub fn with_payload_schema(mut self, schema: Option<String>) -> Self {
155 self.payload_schema = schema;
156 self
157 }
158
159 /// Builder-style setter for the serialized headers schema.
160 #[must_use]
161 pub fn with_headers_schema(mut self, schema: Option<String>) -> Self {
162 self.headers_schema = schema;
163 self
164 }
165
166 /// Builder-style setter for the serialized-wire marker (see [`serialized`](Self::serialized)).
167 #[must_use]
168 pub fn with_serialized(mut self, serialized: bool) -> Self {
169 self.serialized = serialized;
170 self
171 }
172
173 /// Builder-style setter for the media type this message leaves in (see
174 /// [`content_type`](Self::content_type)).
175 ///
176 /// # Examples
177 ///
178 /// ```
179 /// use ruststream::runtime::OutgoingMessageMetadata;
180 ///
181 /// let entry = OutgoingMessageMetadata::new("events.progress", "Progress")
182 /// .with_content_type(Some("application/json"));
183 ///
184 /// assert_eq!(entry.content_type, Some("application/json"));
185 /// ```
186 #[must_use]
187 pub const fn with_content_type(mut self, content_type: Option<&'static str>) -> Self {
188 self.content_type = content_type;
189 self
190 }
191}
192
193/// What one bound publish position says about itself, read off its policy at mount time.
194///
195/// The three publish positions - a reply, an [`Out`](crate::runtime::Out) slot, the publisher a
196/// dead-lettered delivery leaves through - each resolve one of these and write it onto the
197/// [`OutgoingMessageMetadata`] entries they own. Nothing here runs per message.
198#[derive(Debug, Clone, Default)]
199pub(crate) struct PublishDescription {
200 /// The media type the position's codec encodes in, where one does.
201 pub(crate) content_type: Option<&'static str>,
202 /// The policy's reply address expression, carried only where a transform on the position
203 /// names the destination per delivery.
204 pub(crate) reply_address_location: Option<&'static str>,
205 /// What the policy adds to the document at each level.
206 #[cfg(feature = "asyncapi")]
207 pub(crate) bindings: PublishBindings,
208}
209
210impl PublishDescription {
211 /// Reads the description off `policy`, against the broker it pairs with, for the one channel
212 /// the position publishes to.
213 ///
214 /// `channel` is the destination the mount site resolved, which is what the document reports
215 /// as the channel's address; a position whose transform names the destination per delivery
216 /// reports none, and the mount site's fallback name is what the policy is asked about.
217 ///
218 /// `names_destination` is what the mount site's transform stack declared: only a position
219 /// that names the destination per delivery has a reply address to report, because every
220 /// other one publishes to the name the document already carries.
221 pub(crate) fn of<C, P>(
222 policy: &P,
223 content_type: Option<&'static str>,
224 names_destination: bool,
225 channel: &str,
226 ) -> Self
227 where
228 C: ConnectedBroker,
229 P: PublishPolicy<C> + ?Sized,
230 {
231 let _ = (policy, names_destination, channel);
232 #[cfg(not(feature = "asyncapi"))]
233 let description = Self {
234 content_type,
235 reply_address_location: None,
236 };
237 #[cfg(feature = "asyncapi")]
238 let description = Self {
239 content_type,
240 reply_address_location: names_destination
241 .then(|| policy.reply_address_location())
242 .flatten(),
243 bindings: PublishBindings {
244 channel: policy.channel_bindings(channel),
245 operation: policy.operation_bindings(channel),
246 message: policy.message_bindings(channel),
247 },
248 };
249 description
250 }
251
252 /// Writes what the position says onto one entry.
253 ///
254 /// A message riding the serialized wire carries bytes that are their own wire format, so it
255 /// takes no media type from the position's codec: nothing encoded it.
256 fn apply(&self, entry: &mut OutgoingMessageMetadata) {
257 entry.content_type = (!entry.serialized).then_some(self.content_type).flatten();
258 entry.reply_address_location = self.reply_address_location;
259 #[cfg(feature = "asyncapi")]
260 {
261 entry.bindings = self.bindings.clone();
262 }
263 }
264}
265
266/// Descriptive metadata for a registered subscriber handler.
267///
268/// Collected by the router so downstream tools (`AsyncAPI` generator, dashboards, CLI) can
269/// describe the service without re-parsing source code.
270#[derive(Debug, Clone)]
271#[non_exhaustive]
272pub struct HandlerMetadata {
273 /// Broker name / subject the handler is bound to.
274 pub name: Cow<'static, str>,
275 /// Optional broker-provided routing key, when the broker distinguishes from `name`.
276 pub routing_key: Option<Cow<'static, str>>,
277 /// Type name of the decoded input value, as captured at registration time.
278 pub input_type: &'static str,
279 /// Type name of the response value, when the handler produces one (e.g. request / reply).
280 pub output_type: Option<&'static str>,
281 /// Free-form human description, typically pulled from a doc comment on the handler.
282 pub description: Option<Cow<'static, str>>,
283 /// The input type's JSON Schema, serialized, when the type implements
284 /// [`schemars::JsonSchema`] (captured under the `asyncapi` feature). Feeds the `AsyncAPI`
285 /// message payload schema.
286 pub payload_schema: Option<String>,
287 /// The typed header contract's JSON Schema, serialized, when the handler declares one (a
288 /// `Headers<T>` parameter, captured under the `asyncapi` feature). Feeds the `AsyncAPI`
289 /// message headers schema.
290 pub headers_schema: Option<String>,
291 /// The input type's [`Message`](crate::MessageInfo) name, when it implements that trait. Overrides
292 /// the `input_type`-derived name in the `AsyncAPI` document.
293 pub message_name: Option<Cow<'static, str>>,
294 /// The input type's [`Message`](crate::MessageInfo) description, when it implements that trait.
295 /// Feeds the `AsyncAPI` message description.
296 pub message_description: Option<Cow<'static, str>>,
297 /// The messages this handler publishes, when it declares them: the reply of a
298 /// `reply("dest")` form, and every entry of an `Out` slot's `#[publishes(..)]`
299 /// dictionary. Feeds the `AsyncAPI` `send` operations.
300 pub outgoing: Vec<OutgoingMessageMetadata>,
301 /// True when the input rides the self-deserializing lane
302 /// ([`Deserialized`](crate::runtime::Deserialized)): the payload has no serde model, so
303 /// the missing schema is by design rather than a documentation gap.
304 pub deserialized: bool,
305 /// The name of the `AsyncAPI` server this handler's broker was registered under, when the
306 /// registration carries one: the label of
307 /// [`with_broker_labeled`](crate::runtime::RustStream::with_broker_labeled). Feeds the
308 /// channel's `servers` list, so a multi-broker document says which broker a channel lives
309 /// on instead of showing every channel on every server.
310 ///
311 /// A cross-broker publish is outside what this can answer: a
312 /// [`Bound`](crate::runtime::Bound) token publishes against its own broker, which the
313 /// registration's label does not name.
314 pub server: Option<Cow<'static, str>>,
315 /// The media type the codec decoding this subscription produces, when a codec decodes it
316 /// at all ([`Codec::CONTENT_TYPE`](crate::codec::Codec::CONTENT_TYPE)). `None` on the
317 /// self-deserializing lane, whose bytes are their own wire format.
318 pub content_type: Option<&'static str>,
319 /// What the registration declared about retrying a failed delivery: the attempt cap and the
320 /// dead-letter destination. Empty unless the mount site declared one.
321 pub retry: RetryDeclaration,
322 /// What the subscription descriptor adds to the generated document at each level. Empty
323 /// unless the broker's descriptor fills it in.
324 #[cfg(feature = "asyncapi")]
325 pub bindings: SubscriptionBindings,
326}
327
328impl HandlerMetadata {
329 /// Constructs metadata for a raw-bytes handler bound to a name.
330 #[must_use]
331 pub fn raw(name: impl Into<Cow<'static, str>>) -> Self {
332 Self {
333 name: name.into(),
334 routing_key: None,
335 input_type: "bytes",
336 output_type: None,
337 description: None,
338 payload_schema: None,
339 headers_schema: None,
340 message_name: None,
341 message_description: None,
342 outgoing: Vec::new(),
343 deserialized: false,
344 server: None,
345 content_type: None,
346 retry: RetryDeclaration::new(),
347 #[cfg(feature = "asyncapi")]
348 bindings: SubscriptionBindings::default(),
349 }
350 }
351
352 /// Constructs metadata for a typed handler. The input type name is captured via
353 /// [`std::any::type_name`].
354 #[must_use]
355 pub fn typed<T>(name: impl Into<Cow<'static, str>>) -> Self {
356 let _ = PhantomData::<T>;
357 Self {
358 name: name.into(),
359 routing_key: None,
360 input_type: type_name::<T>(),
361 output_type: None,
362 description: None,
363 payload_schema: None,
364 headers_schema: None,
365 message_name: None,
366 message_description: None,
367 outgoing: Vec::new(),
368 deserialized: false,
369 server: None,
370 content_type: None,
371 retry: RetryDeclaration::new(),
372 #[cfg(feature = "asyncapi")]
373 bindings: SubscriptionBindings::default(),
374 }
375 }
376
377 /// Builder-style setter for the handler description.
378 #[must_use]
379 pub fn with_description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
380 self.description = Some(description.into());
381 self
382 }
383
384 /// Builder-style setter for the broker routing key.
385 #[must_use]
386 pub fn with_routing_key(mut self, key: impl Into<Cow<'static, str>>) -> Self {
387 self.routing_key = Some(key.into());
388 self
389 }
390
391 /// Builder-style setter for the response type name.
392 #[must_use]
393 pub fn with_output_type(mut self, name: &'static str) -> Self {
394 self.output_type = Some(name);
395 self
396 }
397
398 /// Builder-style setter for the serialized input payload schema.
399 #[must_use]
400 pub fn with_payload_schema(mut self, schema: impl Into<String>) -> Self {
401 self.payload_schema = Some(schema.into());
402 self
403 }
404
405 /// Builder-style setter for the serialized header contract schema.
406 #[must_use]
407 pub fn with_headers_schema(mut self, schema: impl Into<String>) -> Self {
408 self.headers_schema = Some(schema.into());
409 self
410 }
411
412 /// Builder-style setter for the [`Message`](crate::MessageInfo) name of the input type.
413 #[must_use]
414 pub fn with_message_name(mut self, name: impl Into<Cow<'static, str>>) -> Self {
415 self.message_name = Some(name.into());
416 self
417 }
418
419 /// Builder-style setter for the [`Message`](crate::MessageInfo) description of the input type.
420 #[must_use]
421 pub fn with_message_description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
422 self.message_description = Some(description.into());
423 self
424 }
425
426 /// Records the media type the mounted codec decodes this subscription with, taken from the
427 /// input kind so a byte input (which mounts with no codec) reports none.
428 #[must_use]
429 pub(crate) fn decoded_with<Input, DecodeCodec>(mut self) -> Self
430 where
431 Input: DecodeWith<DecodeCodec>,
432 {
433 self.content_type = <Input as DecodeWith<DecodeCodec>>::CONTENT_TYPE;
434 self
435 }
436
437 /// The same for a single-delivery input, which materializes from the subscription's
438 /// delivery type `M`: a carried value is read by no codec, so it reports none either.
439 #[must_use]
440 pub(crate) fn materialized_with<Input, Decoder, M>(mut self) -> Self
441 where
442 Input: Materialize<Decoder, M>,
443 {
444 self.content_type = <Input as Materialize<Decoder, M>>::CONTENT_TYPE;
445 self
446 }
447
448 /// Writes what the reply position's policy says onto the entry the reply declared.
449 ///
450 /// The policy is asked about the destination that entry names, so a broker whose binding
451 /// carries the channel's own name fills it from the mount site rather than from itself.
452 pub(crate) fn describe_reply(&mut self, describe: impl Fn(&str) -> PublishDescription) {
453 for entry in &mut self.outgoing {
454 if entry.kind == OutgoingKind::Answer {
455 let description = describe(entry.channel.as_ref());
456 description.apply(entry);
457 }
458 }
459 }
460
461 /// Writes what one slot's policy says onto the entries its marker declared, matched by the
462 /// destination each entry names.
463 ///
464 /// A slot's dictionary names several destinations and one policy answers for each of them,
465 /// so `described` carries the policy's answer per destination.
466 pub(crate) fn describe_slot(&mut self, described: &[(Cow<'static, str>, PublishDescription)]) {
467 for entry in &mut self.outgoing {
468 if entry.kind == OutgoingKind::SlotEntry
469 && let Some((_, description)) = described
470 .iter()
471 .find(|(channel, _)| *channel == entry.channel)
472 {
473 description.apply(entry);
474 }
475 }
476 }
477
478 /// Writes what the retry publisher's policy says onto the entries that publisher owns: the
479 /// dead-letter destination, and the destination a `.to(name)` gave the retry copies.
480 ///
481 /// Both carry the delivery's own bytes, so the media type stays the one the subscription
482 /// decodes rather than anything the retry publisher would encode.
483 pub(crate) fn describe_copies(&mut self, describe: impl Fn(&str) -> PublishDescription) {
484 let content_type = self.content_type;
485 for entry in &mut self.outgoing {
486 if matches!(
487 entry.kind,
488 OutgoingKind::DeadLetter | OutgoingKind::RetryCopy
489 ) {
490 let description = describe(entry.channel.as_ref());
491 description.apply(entry);
492 entry.content_type = content_type;
493 }
494 }
495 }
496
497 /// Declares a channel this registration republishes a delivery to, under the input's own
498 /// name and schema: the copy is the delivery, byte for byte.
499 ///
500 /// Both destinations a retry can name come through here - where a spent delivery is
501 /// dead-lettered, and where a deferred copy is published - so the two are documented alike.
502 pub(crate) fn declare_copy(&mut self, destination: Cow<'static, str>, kind: OutgoingKind) {
503 let entry = OutgoingMessageMetadata::new(destination, self.input_type)
504 .with_payload_schema(self.payload_schema.clone())
505 .with_headers_schema(self.headers_schema.clone())
506 .with_kind(kind);
507 self.outgoing.push(entry);
508 }
509
510 /// Records what the subscription descriptor adds to the generated document.
511 ///
512 /// Without the `asyncapi` feature there is no document and no descriptor to ask, so the call
513 /// carries the metadata through unchanged.
514 #[must_use]
515 pub(crate) fn describing<C, S>(self, source: &S) -> Self
516 where
517 C: ConnectedBroker,
518 S: SubscriptionSource<C>,
519 {
520 let _ = source;
521 #[cfg(not(feature = "asyncapi"))]
522 let this = self;
523 #[cfg(feature = "asyncapi")]
524 let this = Self {
525 bindings: SubscriptionBindings {
526 channel: source.channel_bindings(),
527 operation: source.operation_bindings(),
528 message: source.message_bindings(),
529 },
530 ..self
531 };
532 this
533 }
534
535 /// Attaches the optional descriptive fields that every generated definition trait exposes
536 /// with identical signatures (`description`, `input_schema`, `headers_schema`,
537 /// `message_name`, `message_description`). Shared tail of the per-definition metadata
538 /// builders.
539 #[must_use]
540 pub(crate) fn with_def_details(
541 mut self,
542 description: Option<&str>,
543 input_schema: Option<String>,
544 headers_schema: Option<String>,
545 message_name: Option<&'static str>,
546 message_description: Option<&'static str>,
547 ) -> Self {
548 if let Some(description) = description {
549 self = self.with_description(description.to_owned());
550 }
551 if let Some(schema) = input_schema {
552 self = self.with_payload_schema(schema);
553 }
554 if let Some(schema) = headers_schema {
555 self = self.with_headers_schema(schema);
556 }
557 if let Some(name) = message_name {
558 self = self.with_message_name(name);
559 }
560 if let Some(description) = message_description {
561 self = self.with_message_description(description);
562 }
563 self
564 }
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570
571 #[test]
572 fn builder_tail_attaches_every_optional_detail() {
573 let meta = HandlerMetadata::raw("orders")
574 .with_routing_key("orders.eu")
575 .with_def_details(
576 Some("handles orders"),
577 Some("{}".to_owned()),
578 Some("{\"type\":\"object\"}".to_owned()),
579 Some("Order"),
580 Some("an order event"),
581 );
582 assert_eq!(meta.routing_key.as_deref(), Some("orders.eu"));
583 assert_eq!(meta.description.as_deref(), Some("handles orders"));
584 assert_eq!(meta.payload_schema.as_deref(), Some("{}"));
585 assert_eq!(
586 meta.headers_schema.as_deref(),
587 Some("{\"type\":\"object\"}")
588 );
589 assert_eq!(meta.message_name.as_deref(), Some("Order"));
590 assert_eq!(meta.message_description.as_deref(), Some("an order event"));
591
592 // The all-None tail changes nothing.
593 let plain = HandlerMetadata::raw("orders").with_def_details(None, None, None, None, None);
594 assert!(plain.description.is_none());
595 assert!(plain.payload_schema.is_none());
596 assert!(plain.headers_schema.is_none());
597 }
598
599 /// A hand-written definition states the media type of what it publishes the same way the
600 /// mount chain does.
601 #[test]
602 fn an_outgoing_entry_takes_a_media_type_from_its_builder() {
603 let entry = OutgoingMessageMetadata::new("events.progress", "Progress")
604 .with_content_type(Some("application/json"));
605
606 assert_eq!(entry.content_type, Some("application/json"));
607 assert_eq!(
608 OutgoingMessageMetadata::new("events.progress", "Progress").content_type,
609 None,
610 );
611 }
612}