Skip to main content

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}