Skip to main content

zerodds_webdds_bridge/
contained_entities.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Parses the contained-entity lists Spec §7.4.3.1/§7.4.3.2
5//! (`participantObjectRepresentation`), §7.4.4.7/§7.4.4.8
6//! (`publisherObjectRepresentation`) and §7.4.4.11/§7.4.4.12
7//! (`subscriberObjectRepresentation`) allow: a `DomainParticipant`'s
8//! `<publishers>`/`<subscribers>`, a `Publisher`'s `<data_writers>`, a
9//! `Subscriber`'s `<data_readers>` — each a DDS-XTYPES XML sequence
10//! (`<publishers><element>...</element>...</publishers>`,
11//! `XmlElement::sequence_elements`), each element the same
12//! `entity`-shaped representation (`<name>`, optionally `<qos>`/
13//! `<qos_profile>`) every other WebDDS entity uses.
14//!
15//! Unlike a top-level `create_publisher`/`create_data_writer` (whose
16//! name the gateway always generates, `crate::gateway`'s own module
17//! doc), a *contained* entity's `<name>` is the caller's own —
18//! `update_participant`/`update_publisher`/`update_subscriber`'s own
19//! reconciliation rule ("For each contained entity that already
20//! exists...") only makes sense if the same name in two calls means
21//! the same entity. `crate::gateway::Gateway`'s internal maps already
22//! key by a plain `String`, so this is not a second naming scheme —
23//! just a different source for the key.
24
25use zerodds_web::bridge::BackendError;
26use zerodds_xml::{EntityQos, XmlElement};
27
28use crate::qos_update::EntityKind;
29
30use crate::metadata::MetadataStore;
31
32/// A contained `DataWriter` (Spec §7.4.4.7 rule, within a
33/// `publisherObjectRepresentation`'s `<data_writers>`).
34#[derive(Debug, Clone)]
35pub struct ParsedDataWriter {
36    /// The caller's own identifier for this writer.
37    pub name: String,
38    /// The `Topic` it writes — must already exist on the enclosing
39    /// participant.
40    pub topic_name: String,
41    /// Its QoS/QosProfile, if named.
42    pub qos: Option<EntityQos>,
43}
44
45/// A contained `DataReader`. See [`ParsedDataWriter`].
46#[derive(Debug, Clone)]
47pub struct ParsedDataReader {
48    /// The caller's own identifier for this reader.
49    pub name: String,
50    /// The `Topic` it reads — must already exist on the enclosing
51    /// participant.
52    pub topic_name: String,
53    /// Its QoS/QosProfile, if named.
54    pub qos: Option<EntityQos>,
55}
56
57/// A contained `Publisher`, with its own contained `DataWriter`s.
58#[derive(Debug, Clone)]
59pub struct ParsedPublisher {
60    /// The caller's own identifier for this publisher.
61    pub name: String,
62    /// Its QoS/QosProfile, if named.
63    pub qos: Option<EntityQos>,
64    /// Contained `DataWriter`s, in document order.
65    pub data_writers: Vec<ParsedDataWriter>,
66}
67
68/// A contained `Subscriber`, with its own contained `DataReader`s.
69#[derive(Debug, Clone)]
70pub struct ParsedSubscriber {
71    /// The caller's own identifier for this subscriber.
72    pub name: String,
73    /// Its QoS/QosProfile, if named.
74    pub qos: Option<EntityQos>,
75    /// Contained `DataReader`s, in document order.
76    pub data_readers: Vec<ParsedDataReader>,
77}
78
79fn required_name(el: &XmlElement, what: &str) -> Result<String, BackendError> {
80    el.child("name")
81        .map(|e| e.text.clone())
82        .filter(|s| !s.is_empty())
83        .ok_or_else(|| BackendError::BadRequest(format!("a contained {what} needs a <name>")))
84}
85
86/// Parses `root`'s `<publishers>` sequence, if present — empty where
87/// `root` names none, which is a legitimate "no contained publishers",
88/// not an error.
89///
90/// # Errors
91/// [`BackendError::BadRequest`] if an element is missing `<name>`, or
92/// its own QoS/QosProfile does not resolve (see
93/// `crate::qos_update::resolve_entity_qos_from_element`).
94pub fn parse_publishers(
95    root: &XmlElement,
96    metadata: &MetadataStore,
97) -> Result<Vec<ParsedPublisher>, BackendError> {
98    let Some(container) = root.child("publishers") else {
99        return Ok(Vec::new());
100    };
101    container
102        .sequence_elements()
103        .map(|el| parse_publisher(el, metadata))
104        .collect()
105}
106
107fn parse_publisher(
108    el: &XmlElement,
109    metadata: &MetadataStore,
110) -> Result<ParsedPublisher, BackendError> {
111    let name = required_name(el, "publisher")?;
112    let qos =
113        crate::qos_update::resolve_entity_qos_from_element(el, metadata, EntityKind::Publisher)?;
114    let data_writers = parse_data_writers(el, metadata)?;
115    Ok(ParsedPublisher {
116        name,
117        qos,
118        data_writers,
119    })
120}
121
122/// Parses `root`'s `<data_writers>` sequence, if present.
123///
124/// # Errors
125/// See [`parse_publishers`].
126pub fn parse_data_writers(
127    root: &XmlElement,
128    metadata: &MetadataStore,
129) -> Result<Vec<ParsedDataWriter>, BackendError> {
130    let Some(container) = root.child("data_writers") else {
131        return Ok(Vec::new());
132    };
133    container
134        .sequence_elements()
135        .map(|el| {
136            let name = required_name(el, "data_writer")?;
137            let topic_name = el
138                .child("topic_name")
139                .map(|e| e.text.clone())
140                .unwrap_or_default();
141            // §7.3.2.4.3: the profile's `topic_filter`s are evaluated
142            // against this writer's topic name.
143            let qos = crate::qos_update::resolve_entity_qos_from_element_for_topic(
144                el,
145                metadata,
146                EntityKind::DataWriter,
147                &topic_name,
148            )?;
149            Ok(ParsedDataWriter {
150                name,
151                topic_name,
152                qos,
153            })
154        })
155        .collect()
156}
157
158/// Parses `root`'s `<subscribers>` sequence, if present.
159///
160/// # Errors
161/// See [`parse_publishers`].
162pub fn parse_subscribers(
163    root: &XmlElement,
164    metadata: &MetadataStore,
165) -> Result<Vec<ParsedSubscriber>, BackendError> {
166    let Some(container) = root.child("subscribers") else {
167        return Ok(Vec::new());
168    };
169    container
170        .sequence_elements()
171        .map(|el| parse_subscriber(el, metadata))
172        .collect()
173}
174
175fn parse_subscriber(
176    el: &XmlElement,
177    metadata: &MetadataStore,
178) -> Result<ParsedSubscriber, BackendError> {
179    let name = required_name(el, "subscriber")?;
180    let qos =
181        crate::qos_update::resolve_entity_qos_from_element(el, metadata, EntityKind::Subscriber)?;
182    let data_readers = parse_data_readers(el, metadata)?;
183    Ok(ParsedSubscriber {
184        name,
185        qos,
186        data_readers,
187    })
188}
189
190/// Parses `root`'s `<data_readers>` sequence, if present.
191///
192/// # Errors
193/// See [`parse_publishers`].
194pub fn parse_data_readers(
195    root: &XmlElement,
196    metadata: &MetadataStore,
197) -> Result<Vec<ParsedDataReader>, BackendError> {
198    let Some(container) = root.child("data_readers") else {
199        return Ok(Vec::new());
200    };
201    container
202        .sequence_elements()
203        .map(|el| {
204            let name = required_name(el, "data_reader")?;
205            let topic_name = el
206                .child("topic_name")
207                .map(|e| e.text.clone())
208                .unwrap_or_default();
209            // §7.3.2.4.3: the profile's `topic_filter`s are evaluated
210            // against this reader's topic name.
211            let qos = crate::qos_update::resolve_entity_qos_from_element_for_topic(
212                el,
213                metadata,
214                EntityKind::DataReader,
215                &topic_name,
216            )?;
217            Ok(ParsedDataReader {
218                name,
219                topic_name,
220                qos,
221            })
222        })
223        .collect()
224}
225
226/// Parses the whole `body` (a `participant`/`publisher`/`subscriber`
227/// object representation) into its root element, or `None` for an
228/// empty body — the same "no representation named, nothing to do"
229/// no-op every `update_*` already treats as legitimate.
230///
231/// # Errors
232/// [`BackendError::BadRequest`] if `body` is not UTF-8 or not
233/// well-formed XML.
234pub fn parse_body(body: &[u8]) -> Result<Option<zerodds_xml::DdsXmlDocument>, BackendError> {
235    if body.is_empty() {
236        return Ok(None);
237    }
238    let text = core::str::from_utf8(body)
239        .map_err(|e| BackendError::BadRequest(format!("body is not UTF-8: {e}")))?;
240    if text.trim().is_empty() {
241        return Ok(None);
242    }
243    zerodds_xml::parse_xml_tree(text)
244        .map(Some)
245        .map_err(|e| BackendError::BadRequest(format!("malformed object body: {e}")))
246}
247
248#[cfg(test)]
249#[allow(clippy::unwrap_used, clippy::expect_used)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn a_body_with_no_publishers_element_has_none() {
255        let doc = parse_body(b"<domain_participant><domain_id>0</domain_id></domain_participant>")
256            .unwrap()
257            .unwrap();
258        assert!(
259            parse_publishers(&doc.root, &MetadataStore::new())
260                .unwrap()
261                .is_empty()
262        );
263    }
264
265    #[test]
266    fn nested_publishers_and_data_writers_parse() {
267        let body = b"<domain_participant><publishers><element><name>pub1</name><data_writers><element><name>dw1</name><topic_name>Sensor</topic_name></element></data_writers></element></publishers></domain_participant>";
268        let doc = parse_body(body).unwrap().unwrap();
269        let pubs = parse_publishers(&doc.root, &MetadataStore::new()).unwrap();
270        assert_eq!(pubs.len(), 1);
271        assert_eq!(pubs[0].name, "pub1");
272        assert_eq!(pubs[0].data_writers.len(), 1);
273        assert_eq!(pubs[0].data_writers[0].name, "dw1");
274        assert_eq!(pubs[0].data_writers[0].topic_name, "Sensor");
275    }
276
277    #[test]
278    fn a_contained_entity_without_a_name_is_a_bad_request() {
279        let body =
280            b"<domain_participant><publishers><element><data_writers/></element></publishers></domain_participant>";
281        let doc = parse_body(body).unwrap().unwrap();
282        let err = parse_publishers(&doc.root, &MetadataStore::new()).unwrap_err();
283        assert!(matches!(err, BackendError::BadRequest(_)));
284    }
285
286    #[test]
287    fn a_publishers_qos_profile_reference_resolves() {
288        let metadata = MetadataStore::new();
289        metadata
290            .qos_libraries
291            .create(
292                "Lib",
293                crate::metadata::StoredDocument {
294                    content_type: "application/dds-web+xml".to_string(),
295                    body: b"<qos_library><name>Lib</name></qos_library>".to_vec(),
296                },
297            )
298            .unwrap();
299        metadata
300            .qos_profiles
301            .create(
302                &MetadataStore::profile_key("Lib", "P"),
303                crate::metadata::StoredDocument {
304                    content_type: "application/dds-web+xml".to_string(),
305                    body: b"<qos_profile><name>P</name><publisher_qos><partition><name><element>g1</element></name></partition></publisher_qos></qos_profile>".to_vec(),
306                },
307            )
308            .unwrap();
309        let body = b"<domain_participant><publishers><element><name>pub1</name><qos_profile>Lib::P</qos_profile></element></publishers></domain_participant>";
310        let doc = parse_body(body).unwrap().unwrap();
311        let pubs = parse_publishers(&doc.root, &metadata).unwrap();
312        assert_eq!(
313            pubs[0]
314                .qos
315                .as_ref()
316                .unwrap()
317                .partition
318                .as_ref()
319                .unwrap()
320                .names,
321            vec!["g1".to_string()]
322        );
323    }
324}