Skip to main content

phoxal_bus/
topic.rs

1//! Typed topics - the api-local builder output (D61), side-branded for L1 (plan #00).
2//!
3//! A [`Topic`] is a version-qualified topic key plus a phantom [`TopicKind`]
4//! that ties the key to its body type(s) **and to the side** the holder may
5//! take. The api tree's `topic` builders return these; the `SetupContext` handle
6//! builders consume them. The wire body never appears in the key, but the
7//! version does - the key is `v0.1/drive/state`, not `drive/state` (D62/D1):
8//! folding the version into the key is what makes different versioned names
9//! physically distinct Zenoh keys.
10//!
11//! # Side branding (L1)
12//!
13//! The kind marker is the compile-time gate that makes taking the **wrong side**
14//! of a topic a type error. The four markers split each wire shape by side:
15//!
16//! - [`Publish<B>`] - the participant *publishes* `B` (a client sending a command,
17//!   or an owner publishing its state).
18//! - [`Subscribe<B>`] - the participant *subscribes/observes* `B` (a client
19//!   observing state, or an owner reading its command input).
20//! - [`AskQuery<Req, Resp>`] - the **client** side of a query: it *calls* the owner.
21//! - [`ServeQuery<Req, Resp>`] - the **owner** side of a query: it *serves* requests.
22//!
23//! The brand is a COMPILE-TIME marker only: the underlying key and the actual
24//! `Publisher`/`Subscriber`/`Latest`/`Querier`/server ops are unchanged. The api
25//! tree emits the builder tree twice - a public *client* builder and an
26//! explicit *owner* builder over identical keys - so the side a participant gets
27//! is decided by which builder it calls, and a wrong side fails to compile in the
28//! `SetupContext` handle builder that consumes the `Topic`.
29
30use std::borrow::Cow;
31use std::marker::PhantomData;
32
33/// A pub/sub topic the participant **publishes** `B` on (client command, or owner
34/// state). The publish side of the former side-agnostic `PubSub<B>`.
35pub struct Publish<B>(PhantomData<fn() -> B>);
36
37/// A pub/sub topic the participant **subscribes/observes** `B` on (client
38/// observing state, or owner reading its command input). The subscribe side of
39/// the former side-agnostic `PubSub<B>`.
40pub struct Subscribe<B>(PhantomData<fn() -> B>);
41
42/// The **client** side of a query topic carrying request `Req`/response `Resp`:
43/// the holder *calls* the owner. The caller side of the former side-agnostic
44/// `Query<Req, Resp>`.
45pub struct AskQuery<Req, Resp>(PhantomData<fn() -> (Req, Resp)>);
46
47/// The **owner** side of a query topic carrying request `Req`/response `Resp`:
48/// the holder *serves* requests. The server side of the former side-agnostic
49/// `Query<Req, Resp>`.
50pub struct ServeQuery<Req, Resp>(PhantomData<fn() -> (Req, Resp)>);
51
52mod sealed {
53    pub trait Sealed {}
54}
55
56/// Marker for the kind (wire shape + side) of a [`Topic`]. Sealed.
57pub trait TopicKind: sealed::Sealed {}
58
59impl<B> sealed::Sealed for Publish<B> {}
60impl<B> TopicKind for Publish<B> {}
61impl<B> sealed::Sealed for Subscribe<B> {}
62impl<B> TopicKind for Subscribe<B> {}
63impl<Req, Resp> sealed::Sealed for AskQuery<Req, Resp> {}
64impl<Req, Resp> TopicKind for AskQuery<Req, Resp> {}
65impl<Req, Resp> sealed::Sealed for ServeQuery<Req, Resp> {}
66impl<Req, Resp> TopicKind for ServeQuery<Req, Resp> {}
67
68/// A typed topic: a version-qualified key bound to its body type(s) via `Kind`.
69pub struct Topic<Kind> {
70    key: Cow<'static, str>,
71    _kind: PhantomData<Kind>,
72}
73
74impl<Kind> Topic<Kind> {
75    /// Construct a topic from a static key.
76    ///
77    /// `#[doc(hidden)] pub` raw constructor - an unsupported escape hatch, NOT
78    /// part of the authored surface. It is `pub` only because it must be callable
79    /// across the `phoxal-api` / `phoxal-bus` crate split: the `phoxal_api_tree!`
80    /// macro (invoked in the `phoxal-api` crate, where the versioned APIs live)
81    /// calls it over each contract's canonical key, and a `pub(crate)` constructor
82    /// cannot cross that boundary. Author correctness does not come from hiding
83    /// this: it comes from the typed handles and the api-tree builders
84    /// (`api::topic::client()....()`), which keep the typed `Kind` and the bus key in
85    /// lockstep (D61/D62). The owner-side builder (`api::topic::owner()`) makes the intended side
86    /// explicit. This `#[doc(hidden)]` raw constructor remains an escape hatch: it
87    /// is generic over `Kind`, so hand-written code can forge either branded topic.
88    /// That is inherent to the macro/crate split because generated builder code in a
89    /// downstream crate needs a `pub` constructor.
90    #[doc(hidden)]
91    pub fn new_static(key: &'static str) -> Self {
92        Topic {
93            key: Cow::Borrowed(key),
94            _kind: PhantomData,
95        }
96    }
97
98    /// Construct a topic from an owned (dynamically built) key.
99    ///
100    /// `#[doc(hidden)] pub` raw constructor, the owned-key counterpart of
101    /// [`new_static`](Self::new_static): an unsupported escape hatch that is `pub`
102    /// only to cross the `phoxal-api` / `phoxal-bus` crate split. The generated api
103    /// builder calls it for nodes with dynamic segments, filling the carried
104    /// variables into the canonical key. Not part of the authored surface;
105    /// correctness for authors comes from the typed handles + api-tree builders.
106    /// The owner-side builder is [`api::topic::owner()`](https://docs.rs/phoxal-api),
107    /// while this constructor remains an explicit raw escape hatch like
108    /// [`new_static`](Self::new_static).
109    #[doc(hidden)]
110    pub fn new_owned(key: String) -> Self {
111        Topic {
112            key: Cow::Owned(key),
113            _kind: PhantomData,
114        }
115    }
116
117    /// The version-qualified topic key (e.g. `v0.1/drive/state`).
118    pub fn key(&self) -> &str {
119        &self.key
120    }
121
122    /// The key reusable as the publish key. Wildcard topics (`*`) are
123    /// subscribe-only and rejected here before transport.
124    pub fn publish_key(&self) -> Result<&str, WildcardPublish> {
125        if self.key.split('/').any(|seg| seg == "*" || seg == "**") {
126            Err(WildcardPublish {
127                key: self.key.to_string(),
128            })
129        } else {
130            Ok(&self.key)
131        }
132    }
133}
134
135impl<Kind> Clone for Topic<Kind> {
136    fn clone(&self) -> Self {
137        Topic {
138            key: self.key.clone(),
139            _kind: PhantomData,
140        }
141    }
142}
143
144/// Attempted to publish on a wildcard (subscribe-only) topic.
145#[derive(Debug, thiserror::Error)]
146#[error("cannot publish on wildcard topic '{key}' (wildcards are subscribe-only)")]
147pub struct WildcardPublish {
148    /// The offending key.
149    pub key: String,
150}