Skip to main content

zerodds_webdds_bridge/
rpc_dispatch.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Dispatches a SOAP operation of a config-named IDL `interface` as a
5//! DDS-RPC request, and returns the reply — DDS-RPC 1.0 §7.5.1.1 Basic
6//! Service Mapping: **one request/reply topic pair per interface**
7//! (§7.4.3), a `Call`/`Return` union multiplexing every operation onto
8//! it (§7.5.1.1.6/§7.5.1.1.7), each operation's reply case
9//! itself a `Result` union over `dds::RETCODE_OK` (carrying the `Out`
10//! struct) and its declared `raises` exceptions (§7.5.1.1.5). Reuses
11//! `zerodds_lang_rules::adapter::interface` for the operation model —
12//! the same one `crate::wsdl_bridge` builds the WSDL from ("WSDL and
13//! runtime from the same model") — and that crate's own
14//! `operation_hash`/`exception_hash`/`in_struct_name`/`out_struct_name`
15//! for the exact names/discriminators DDS-RPC 1.0 §7.5.1.1.2/§7.5.1.1.6
16//! rule 5a fixes: the same functions `emit::interface_rust`'s generated
17//! Rust binding calls for the identical purpose, so a Requester built
18//! here and a `Replier` `zerodds-idlc generate --lang rust` wrote for
19//! the *same* interface compute the *same* topic name, the *same*
20//! discriminator per operation, and the *same* member layout —
21//! verified end to end in `tests/soap_rpc_e2e.rs` against exactly such
22//! a generated `Replier` (`zerodds-rpc-rust-e2e`'s own `Calculator`
23//! binding).
24//!
25//! # The wire types are synthesized here, not code-generated
26//!
27//! `zerodds_rpc::codegen` describes the same shapes
28//! (`RequestType`/`ReplyType`, `CallUnionDef`, `ResultUnionDef`) for a
29//! *compiled* binding: real structs/enums, a real `DdsType` impl,
30//! written by `idlc` before the binary exists. This gateway has no
31//! compiled type for a business interface it only read from a config
32//! file at start-up — it builds the equivalent `CompleteTypeObject`s
33//! straight from the operation model
34//! (`zerodds_types::from_ast::type_identifier` per parameter) and hands
35//! them to [`crate::dynamic_rpc::DynamicRpcValue`], the same way
36//! `crate::gateway` hands a topic's `CompleteTypeObject` to
37//! `SerializedTypeSupport`. Every synthesized type is `@final` — no
38//! `DHEADER`, classic-CDR shape — matching `emit::interface_rust`'s own
39//! choice for exactly this reason (its module doc: "matching the
40//! classic-CDR shape DDS-RPC 1.0 predates XTypes 1.3's extensibility
41//! kinds with"); building them `@appendable` here once produced bytes a
42//! generated peer's hand-written codec would not decode.
43//!
44//! # A union's `DynamicData` shape
45//!
46//! `zerodds_wire_codec`'s encoder/decoder represent a union value the
47//! same way a struct is represented — a
48//! [`zerodds_types::dynamic::DynamicData::Aggregate`] — with a
49//! `"_d"` entry for the discriminator and one more entry named after
50//! whichever case it selects (`crates/wire-codec/src/encode.rs`'s own
51//! `write_union`: "A union's discriminator and its branch are members,
52//! whatever the union itself was reached as" / "for `(name,
53//! member_value)` in object { if name == "_d" { continue } ... }").
54//! This module builds/reads that shape directly rather than needing a
55//! dedicated union variant.
56
57use std::collections::HashMap;
58use std::sync::{Arc, Mutex};
59use std::time::Duration;
60
61use zerodds_ast::{Ast, ExceptionNode, TypeNode};
62use zerodds_ast_core::Id;
63use zerodds_dcps::participant::DomainParticipant;
64use zerodds_lang_rules::adapter::interface::{
65    self, InterfaceError, Operation, Raise, Service, all_operations_flattened,
66    exception_member_name, in_struct_name, operation_hash, out_struct_name, services,
67};
68use zerodds_rpc::codegen::{
69    DUMMY_MEMBER, RESULT_MEMBER, RETCODE_OK, RETURN_MEMBER, UNKNOWN_OP_MEMBER,
70};
71use zerodds_rpc::endpoint_options::RpcEndpointOptions;
72use zerodds_rpc::qos_profile::RpcQos;
73use zerodds_rpc::requester::Requester;
74use zerodds_rpc::topic_naming::{AnnotatedTopics, BindingStyle};
75use zerodds_types::builder::{Extensibility, TypeObjectBuilder};
76use zerodds_types::dynamic::DynamicData;
77use zerodds_types::{CompleteTypeObject, PrimitiveKind, TypeIdentifier};
78
79use crate::dynamic_rpc::{self, DynamicRpcValue};
80use crate::dynxml;
81use crate::types_registry::{ResolvedType, TypeRegistry};
82
83/// Dispatch error.
84#[derive(Debug)]
85pub enum RpcDispatchError {
86    /// The config-named IDL file's `interface`s did not build (an
87    /// unresolved base, an inheritance cycle).
88    Model(String),
89    /// No operation of any configured interface has this name.
90    UnknownOperation(String),
91    /// Building a synthetic `CompleteTypeObject` refused a parameter's
92    /// type — XTypes 1.3 §7.3 has no `TypeIdentifier` for it, or it is
93    /// recursive.
94    UnsupportedParameterType(String),
95    /// The request body did not convert to the `In` struct's shape.
96    BadRequest(String),
97    /// The `Requester`/DCPS layer refused the call.
98    Rpc(String),
99    /// The remote side reported a foundation-level `RemoteExceptionCode
100    /// != Ok` (Spec §7.6.2) — a protocol-level refusal (e.g. the
101    /// `Replier` itself does not recognize the discriminator), not a
102    /// declared IDL `raises` exception (see
103    /// [`Self::OperationException`]).
104    RemoteException(u32),
105    /// The reply's `Result` union (Spec §7.5.1.1.5) selected a declared
106    /// `raises` exception's case, not `dds::RETCODE_OK` — the call
107    /// reached the service and it refused *this* request specifically.
108    OperationException {
109        /// The operation that raised it.
110        operation: String,
111        /// The exception's simple IDL name.
112        exception: String,
113        /// Its fields, as the same XML representation a successful
114        /// reply's `Out` struct would use.
115        fields_xml: String,
116    },
117    /// The call timed out waiting for a reply.
118    Timeout,
119}
120
121impl std::fmt::Display for RpcDispatchError {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        match self {
124            Self::Model(m) => write!(f, "interface model: {m}"),
125            Self::UnknownOperation(op) => {
126                write!(f, "no configured interface declares operation `{op}`")
127            }
128            Self::UnsupportedParameterType(m) => write!(f, "unsupported parameter type: {m}"),
129            Self::BadRequest(m) => write!(f, "bad request: {m}"),
130            Self::Rpc(m) => write!(f, "rpc: {m}"),
131            Self::RemoteException(code) => write!(f, "remote exception, code {code}"),
132            Self::OperationException {
133                operation,
134                exception,
135                fields_xml,
136            } => write!(
137                f,
138                "operation `{operation}` raised `{exception}`: {fields_xml}"
139            ),
140            Self::Timeout => f.write_str("timed out waiting for a reply"),
141        }
142    }
143}
144
145impl std::error::Error for RpcDispatchError {}
146
147/// Default bound on one RPC round trip.
148const RPC_TIMEOUT: Duration = Duration::from_secs(10);
149
150/// One operation's wire model within its interface's `Call`/`Return`
151/// unions.
152struct OpModel {
153    /// `${I}_${op}_In`'s [`ResolvedType`] — the `Call` union's case
154    /// type for this operation.
155    in_resolved: ResolvedType,
156    /// `${I}_${op}_Result`'s [`ResolvedType`] —
157    /// the `Return` union's case type; its own `dds::RETCODE_OK` case
158    /// carries [`Self::out_resolved`], one more case per
159    /// [`Self::exceptions`]. `dispatch()` never looks this up again by
160    /// itself — the type is already reachable through
161    /// `InterfaceModel::return_resolved`'s own dependency closure, one
162    /// case type among others — so only this module's own tests read
163    /// the field back, to assert the model built the right shape.
164    #[cfg_attr(not(test), allow(dead_code))]
165    result_resolved: ResolvedType,
166    /// `${I}_${op}_Out`'s [`ResolvedType`].
167    out_resolved: ResolvedType,
168    /// One entry per `raises` exception: its discriminator (Spec
169    /// §7.5.1.1.5 rule 4b), the `Result` union's member name for it
170    /// (rule 5b), the exception's simple name, and its own
171    /// [`ResolvedType`].
172    exceptions: Vec<(i32, String, String, ResolvedType)>,
173}
174
175/// One interface's whole DDS-RPC wire model — built once, cached for
176/// the daemon's lifetime (an `interfaces:` file does not change
177/// without a restart).
178struct InterfaceModel {
179    /// `${I}_Call`'s [`ResolvedType`] — the `Requester`/`Replier`'s
180    /// `TIn`/`TOut` wire type on the request/reply topic respectively.
181    call_resolved: ResolvedType,
182    /// `${I}_Return`'s [`ResolvedType`].
183    return_resolved: ResolvedType,
184    /// Keyed by [`operation_hash`] of the operation's own (unqualified)
185    /// name — the same key both the `Call` and the `Return` union
186    /// case use (DDS-RPC 1.0 §7.5.1.1.6 rule 5a/§7.5.1.1.7).
187    operations: HashMap<i32, OpModel>,
188}
189
190/// Dispatches business-interface operations as DDS-RPC calls.
191///
192/// One instance per daemon, built from every config-named `interfaces:`
193/// IDL file, sharing one dedicated client `DomainParticipant`
194/// (`daemon::server` creates it on the daemon's configured `domain`).
195pub struct RpcDispatcher {
196    ast: Arc<Ast>,
197    services: Vec<Service>,
198    participant: DomainParticipant,
199    types: Arc<TypeRegistry>,
200    models: Mutex<HashMap<String, Arc<InterfaceModel>>>,
201    requesters: Mutex<HashMap<String, Arc<Requester<DynamicRpcValue, DynamicRpcValue>>>>,
202}
203
204impl RpcDispatcher {
205    /// Parses every interface out of `ast` (already the union of every
206    /// config-named `interfaces:` file — `daemon::server` builds it the
207    /// same way it builds the WSDL).
208    ///
209    /// # Errors
210    /// [`RpcDispatchError::Model`] on a malformed interface.
211    pub fn new(ast: Arc<Ast>, participant: DomainParticipant) -> Result<Self, RpcDispatchError> {
212        let services = services(&ast).map_err(interface_error)?;
213        Ok(Self {
214            ast,
215            services,
216            participant,
217            types: Arc::new(TypeRegistry::new()),
218            models: Mutex::new(HashMap::new()),
219            requesters: Mutex::new(HashMap::new()),
220        })
221    }
222
223    /// Finds the `(Service, Operation)` pair a bare operation name
224    /// (as a SOAP body's root element names it) resolves to, across
225    /// every configured interface's flattened operation list
226    /// (declared + inherited + attribute-implied).
227    fn find_operation(&self, op_name: &str) -> Result<(&Service, Operation), RpcDispatchError> {
228        for service in &self.services {
229            let flattened =
230                all_operations_flattened(&self.ast, service.id).map_err(interface_error)?;
231            if let Some(op) = flattened.into_iter().find(|op| op.name == op_name) {
232                return Ok((service, op));
233            }
234        }
235        Err(RpcDispatchError::UnknownOperation(op_name.to_string()))
236    }
237
238    /// `true` if `op_name` names an operation of a configured
239    /// interface — `soap_service` uses this to decide whether a
240    /// non-builtin operation name is an RPC call or genuinely unknown.
241    #[must_use]
242    pub fn knows_operation(&self, op_name: &str) -> bool {
243        self.find_operation(op_name).is_ok()
244    }
245
246    /// The cached [`InterfaceModel`] for `service`, building it on
247    /// first use.
248    fn model_for(&self, service: &Service) -> Result<Arc<InterfaceModel>, RpcDispatchError> {
249        {
250            let models = self
251                .models
252                .lock()
253                .unwrap_or_else(std::sync::PoisonError::into_inner);
254            if let Some(m) = models.get(&service.qualified_name) {
255                return Ok(Arc::clone(m));
256            }
257        }
258        let model = Arc::new(build_interface_model(&self.ast, service, &self.types)?);
259        let mut models = self
260            .models
261            .lock()
262            .unwrap_or_else(std::sync::PoisonError::into_inner);
263        models.insert(service.qualified_name.clone(), Arc::clone(&model));
264        Ok(model)
265    }
266
267    /// Dispatches `op_name` with `request_body_xml` (the SOAP body's
268    /// operation element, e.g. `<add><a>3</a><b>4</b></add>`) as a
269    /// DDS-RPC call, and returns the reply's body XML (e.g.
270    /// `<data><return_>7</return_></data>`) — empty for an operation with
271    /// no outputs and no return value.
272    ///
273    /// # Errors
274    /// See [`RpcDispatchError`].
275    pub fn dispatch(
276        &self,
277        op_name: &str,
278        request_body_xml: &str,
279    ) -> Result<String, RpcDispatchError> {
280        let (service, operation) = self.find_operation(op_name)?;
281        let model = self.model_for(service)?;
282        let disc = operation_hash(&operation.name);
283        let op_model = model.operations.get(&disc).ok_or_else(|| {
284            RpcDispatchError::Model(format!(
285                "operation `{op_name}`: not found in its own interface's model"
286            ))
287        })?;
288
289        let in_data =
290            dynxml::from_xml_document(request_body_xml, &op_model.in_resolved.dynamic_type)
291                .map_err(|e| RpcDispatchError::BadRequest(e.to_string()))?;
292        let call_ast = Arc::clone(model.call_resolved.dynamic_type.ast());
293        let call_type_name = model.call_resolved.dynamic_type.name().to_string();
294        let call_data = DynamicData::Aggregate(vec![
295            (
296                "_d".to_string(),
297                DynamicData::integer(zerodds_ast::PrimitiveKind::Int32, i128::from(disc)),
298            ),
299            (operation.name.clone(), in_data),
300        ]);
301        // The participant's `wire.codec_dialect`, for the call it writes and
302        // the reply it reads.
303        let dialect = self
304            .participant
305            .runtime()
306            .map(|rt| rt.codec_dialect())
307            .unwrap_or_default();
308        let call_value =
309            DynamicRpcValue::new(call_ast, call_type_name, call_data).in_dialect(dialect);
310
311        let requester = self.requester_for(service)?;
312        let return_ast = Arc::clone(model.return_resolved.dynamic_type.ast());
313        let return_type_name = model.return_resolved.dynamic_type.name().to_string();
314        let reply =
315            dynamic_rpc::with_decode_context(&return_ast, &return_type_name, dialect, || {
316                requester.send_request_blocking(&call_value, Some(RPC_TIMEOUT))
317            })
318            .map_err(|e| match e {
319                zerodds_rpc::error::RpcError::Timeout => RpcDispatchError::Timeout,
320                zerodds_rpc::error::RpcError::RemoteException(code) => {
321                    RpcDispatchError::RemoteException(code)
322                }
323                other => RpcDispatchError::Rpc(other.to_string()),
324            })?;
325
326        // Unwrap `${I}_Return`'s case (named after the operation) —
327        // the `${I}_${op}_Result` union.
328        let result_data = reply.data.member(&operation.name).ok_or_else(|| {
329            RpcDispatchError::Rpc(format!(
330                "reply named no `{}` case (Return union discriminator mismatch)",
331                operation.name
332            ))
333        })?;
334        let result_disc = match result_data.member("_d") {
335            Some(DynamicData::Integer { value, .. }) => *value as i32,
336            _ => {
337                return Err(RpcDispatchError::Rpc(
338                    "Result union reply carried no discriminator".to_string(),
339                ));
340            }
341        };
342        if result_disc == RETCODE_OK {
343            let out_resolved = &op_model.out_resolved;
344            let out_data = result_data.member(RESULT_MEMBER).ok_or_else(|| {
345                RpcDispatchError::Rpc(format!(
346                    "Result union's `{RESULT_MEMBER}` case named no `{RESULT_MEMBER}` member"
347                ))
348            })?;
349            return dynxml::to_xml_document("data", out_data, &out_resolved.dynamic_type)
350                .map_err(|e| RpcDispatchError::Rpc(e.to_string()));
351        }
352        // A declared exception case.
353        let (_, member_name, exception_name, exc_resolved) = op_model
354            .exceptions
355            .iter()
356            .find(|(disc, _, _, _)| *disc == result_disc)
357            .ok_or_else(|| {
358                RpcDispatchError::Rpc(format!(
359                    "Result union reply named an unrecognised discriminator {result_disc}"
360                ))
361            })?;
362        let exc_data = result_data.member(member_name).ok_or_else(|| {
363            RpcDispatchError::Rpc(format!("Result union named no `{member_name}` member"))
364        })?;
365        let fields_xml = dynxml::to_xml_document("data", exc_data, &exc_resolved.dynamic_type)
366            .map_err(|e| RpcDispatchError::Rpc(e.to_string()))?;
367        Err(RpcDispatchError::OperationException {
368            operation: operation.name.clone(),
369            exception: exception_name.clone(),
370            fields_xml,
371        })
372    }
373
374    /// The `Requester` for `qualified_interface`, created on first use
375    /// against this dispatcher's dedicated client participant — one
376    /// per *interface* (DDS-RPC 1.0 §7.4.3), shared across all its
377    /// operations.
378    fn requester_for(
379        &self,
380        service: &Service,
381    ) -> Result<Arc<Requester<DynamicRpcValue, DynamicRpcValue>>, RpcDispatchError> {
382        let mut requesters = self
383            .requesters
384            .lock()
385            .unwrap_or_else(std::sync::PoisonError::into_inner);
386        if let Some(r) = requesters.get(&service.qualified_name) {
387            return Ok(Arc::clone(r));
388        }
389        // §7.5.1.1.6/7: the topic types are the interface's own, in its
390        // module, which is what the service's endpoints announce.
391        let scope = interface::module_chain(&self.ast, service.module)
392            .map_err(interface_error)?
393            .into_iter()
394            .map(|(_, name)| name)
395            .collect::<Vec<_>>()
396            .join("::");
397        let options = RpcEndpointOptions::for_interface(&scope, &service.name);
398        let requester = Requester::with_options(
399            &self.participant,
400            &service.qualified_name,
401            "",
402            &RpcQos::default(),
403            BindingStyle::FunctionCall,
404            &AnnotatedTopics::none(),
405            &options,
406        )
407        .map_err(|e| RpcDispatchError::Rpc(e.to_string()))?;
408        let requester = Arc::new(requester);
409        requesters.insert(service.qualified_name.clone(), Arc::clone(&requester));
410        Ok(requester)
411    }
412}
413
414fn interface_error(e: InterfaceError) -> RpcDispatchError {
415    RpcDispatchError::Model(format!("{e:?}"))
416}
417
418/// Builds `service`'s whole [`InterfaceModel`] — every operation's
419/// `In`/`Out`/exception structs, its `Result` union, and the
420/// interface-wide `Call`/`Return` unions multiplexing them — and
421/// registers every one of them in `types`.
422fn build_interface_model(
423    ast: &Ast,
424    service: &Service,
425    types: &TypeRegistry,
426) -> Result<InterfaceModel, RpcDispatchError> {
427    let qualified = &service.qualified_name;
428    let flattened = all_operations_flattened(ast, service.id).map_err(interface_error)?;
429
430    let mut call_cases: Vec<(i32, String, ResolvedType)> = Vec::new();
431    let mut return_cases: Vec<(i32, String, ResolvedType)> = Vec::new();
432    let mut operations: HashMap<i32, OpModel> = HashMap::new();
433
434    for op in &flattened {
435        let disc = operation_hash(&op.name);
436
437        let in_type_name = in_struct_name(qualified, &op.name);
438        let in_members: Vec<(String, Id<TypeNode>)> =
439            op.inputs().map(|p| (p.name.clone(), p.type_)).collect();
440        let in_resolved = resolve_synthetic_struct(types, ast, &in_type_name, &in_members)?;
441        call_cases.push((disc, op.name.clone(), in_resolved.clone()));
442
443        let out_type_name = out_struct_name(qualified, &op.name);
444        let mut out_members: Vec<(String, Id<TypeNode>)> =
445            op.outputs().map(|p| (p.name.clone(), p.type_)).collect();
446        if let Some(ret) = op.returns {
447            out_members.push((RETURN_MEMBER.to_string(), ret));
448        }
449        let out_resolved = resolve_synthetic_struct(types, ast, &out_type_name, &out_members)?;
450
451        let mut result_cases: Vec<(i32, String, ResolvedType)> =
452            vec![(RETCODE_OK, RESULT_MEMBER.to_string(), out_resolved.clone())];
453        let mut exceptions = Vec::new();
454        for raise in &op.raises {
455            let ex_disc = interface::exception_hash(&raise.scoped_name);
456            let ex_member = exception_member_name(&raise.name);
457            let ex_type_name = format!("{qualified}_{}_{}_Ex", op.name, raise.name);
458            let ex_members = exception_members(ast, raise)
459                .map_err(RpcDispatchError::UnsupportedParameterType)?;
460            let ex_resolved = resolve_synthetic_struct(types, ast, &ex_type_name, &ex_members)?;
461            result_cases.push((ex_disc, ex_member.clone(), ex_resolved.clone()));
462            exceptions.push((ex_disc, ex_member, raise.name.clone(), ex_resolved));
463        }
464        let result_type_name = format!("{qualified}_{}_Result", op.name);
465        let result_resolved =
466            resolve_synthetic_union(types, &result_type_name, &result_cases, None)?;
467        return_cases.push((disc, op.name.clone(), result_resolved.clone()));
468
469        operations.insert(
470            disc,
471            OpModel {
472                in_resolved,
473                result_resolved,
474                out_resolved,
475                exceptions,
476            },
477        );
478    }
479
480    let call_type_name = format!("{qualified}_Call");
481    let call_resolved =
482        resolve_synthetic_union(types, &call_type_name, &call_cases, Some(UNKNOWN_OP_MEMBER))?;
483    let return_type_name = format!("{qualified}_Return");
484    let return_resolved = resolve_synthetic_union(
485        types,
486        &return_type_name,
487        &return_cases,
488        Some(UNKNOWN_OP_MEMBER),
489    )?;
490
491    Ok(InterfaceModel {
492        call_resolved,
493        return_resolved,
494        operations,
495    })
496}
497
498/// The members of `raise`'s exception declaration — `(name,
499/// Id<TypeNode>)` pairs, the same shape [`resolve_synthetic_struct`]
500/// builds an `In`/`Out` struct from.
501///
502/// # Errors
503/// A named string if the exception's own AST node (or one of its
504/// members) does not resolve — a stale handle, which would mean the
505/// model changed under us mid-request.
506fn exception_members(ast: &Ast, raise: &Raise) -> Result<Vec<(String, Id<TypeNode>)>, String> {
507    let node: &ExceptionNode = ast
508        .exception(raise.id)
509        .map_err(|e| format!("exception `{}`: {e:?}", raise.name))?;
510    node.members
511        .iter()
512        .map(|member_id| {
513            let member = ast
514                .member(*member_id)
515                .map_err(|e| format!("exception `{}`: {e:?}", raise.name))?;
516            let type_id = member.type_.resolved().ok_or_else(|| {
517                format!(
518                    "exception `{}`, member `{}`: type not resolved",
519                    raise.name, member.name
520                )
521            })?;
522            Ok((member.name.clone(), type_id))
523        })
524        .collect()
525}
526
527/// Builds (if not already cached) the `CompleteTypeObject::Struct` for
528/// `type_name` with one member per `(name, type node)` pair — DDS-RPC
529/// 1.0 rule 3a-c (declaration order; a single `dummy` member when the
530/// list is empty) — and registers it.
531fn resolve_synthetic_struct(
532    types: &TypeRegistry,
533    ast: &Ast,
534    type_name: &str,
535    members: &[(String, Id<TypeNode>)],
536) -> Result<ResolvedType, RpcDispatchError> {
537    if let Some(r) = types.get(type_name) {
538        return Ok(r);
539    }
540    let (root, dependencies) = build_synthetic_struct(ast, type_name, members)
541        .map_err(RpcDispatchError::UnsupportedParameterType)?;
542    types
543        .resolve_synthetic(type_name, root, dependencies)
544        .map_err(|e| RpcDispatchError::Model(e.to_string()))
545}
546
547/// Builds a `CompleteTypeObject::Struct` for `type_name` with one
548/// member per `(name, type node)` pair, plus the dependency closure
549/// every named member type needs (its own `CompleteTypeObject`, and
550/// everything *it* reaches — `zerodds_types::from_ast::dependency_type_objects`).
551/// `@final` — no `DHEADER` — matching the classic-CDR shape every
552/// DDS-RPC synthesized type uses (see this module's doc).
553///
554/// # Errors
555/// A named string describing the refusal — a parameter type XTypes 1.3
556/// §7.3 has no `TypeIdentifier` for, or one that is recursive
557/// (`zerodds_types::from_ast::FromAstError`).
558fn build_synthetic_struct(
559    ast: &Ast,
560    type_name: &str,
561    members: &[(String, Id<TypeNode>)],
562) -> Result<(CompleteTypeObject, Vec<CompleteTypeObject>), String> {
563    use zerodds_types::from_ast::{
564        Form, FromAstError, complete_type_object, dependency_type_objects, type_identifier,
565    };
566
567    let mut builder = TypeObjectBuilder::struct_type(type_name).extensibility(Extensibility::Final);
568    let mut dependencies = Vec::new();
569
570    if members.is_empty() {
571        builder = builder.member(
572            DUMMY_MEMBER,
573            TypeIdentifier::Primitive(PrimitiveKind::Byte),
574            |mb| mb,
575        );
576    } else {
577        for (name, type_id) in members {
578            let ti = type_identifier(ast, *type_id, Form::Complete)
579                .map_err(|e| format!("member `{name}`: {e:?}"))?;
580            builder = builder.member(name.clone(), ti, |mb| mb);
581
582            match complete_type_object(ast, *type_id) {
583                Ok(object) => dependencies.push(object),
584                Err(FromAstError::Unrepresentable { .. }) => {
585                    // A plain (fully descriptive) type — primitive,
586                    // string, sequence, array, map — has no
587                    // `TypeObject` behind its `TypeIdentifier`, per
588                    // XTypes 1.3 §7.3.4.6. Nothing to add.
589                }
590                Err(e) => return Err(format!("member `{name}`: {e:?}")),
591            }
592            for dep in dependency_type_objects(ast, *type_id)
593                .map_err(|e| format!("member `{name}`: {e:?}"))?
594            {
595                dependencies.push(dep);
596            }
597        }
598    }
599
600    Ok((
601        CompleteTypeObject::Struct(builder.build_complete()),
602        dependencies,
603    ))
604}
605
606/// Builds (if not already cached) a `CompleteTypeObject::Union` for
607/// `type_name` — discriminator `long` (DDS-RPC 1.0 §7.5.1.1.6 rule 1:
608/// `switch (long _d)`), one case per `(label, member_name,
609/// ResolvedType)` in `cases`, plus `default_member` (named
610/// `UNKNOWN_OP_MEMBER` for `Call`/`Return`, `None` for a `Result`
611/// union, which DDS-RPC 1.0 §7.5.1.1.5 gives no default case at all) —
612/// of type `octet` (`UnknownOperation`/`UnusedMember`, §7.5.1.1.1).
613/// `@final`, same reason as [`build_synthetic_struct`]. The
614/// dependencies are every case's own `CompleteTypeObject` plus its
615/// transitive closure — each case's [`ResolvedType`] already carries
616/// what it needs, recovered here from `types` by name (a synthetic
617/// type is registered under its own name as soon as it is built, so
618/// this is always a cache hit, never a rebuild).
619fn resolve_synthetic_union(
620    types: &TypeRegistry,
621    type_name: &str,
622    cases: &[(i32, String, ResolvedType)],
623    default_member: Option<&str>,
624) -> Result<ResolvedType, RpcDispatchError> {
625    use zerodds_types::from_ast::{
626        Form, complete_type_object, dependency_type_objects, type_identifier,
627    };
628
629    if let Some(r) = types.get(type_name) {
630        return Ok(r);
631    }
632    let mut builder =
633        TypeObjectBuilder::union_type(type_name, TypeIdentifier::Primitive(PrimitiveKind::Int32))
634            .extensibility(Extensibility::Final);
635    let mut dependencies = Vec::new();
636    for (label, member_name, case_resolved) in cases {
637        let case_ast = case_resolved.dynamic_type.ast();
638        let case_id = case_resolved.dynamic_type.id();
639        let case_type_id = type_identifier(case_ast, case_id, Form::Complete)
640            .map_err(|e| RpcDispatchError::Model(format!("{member_name}: {e:?}")))?;
641        builder = builder.case(member_name.clone(), case_type_id, vec![*label]);
642        dependencies.push(
643            complete_type_object(case_ast, case_id)
644                .map_err(|e| RpcDispatchError::Model(format!("{member_name}: {e:?}")))?,
645        );
646        for dep in dependency_type_objects(case_ast, case_id)
647            .map_err(|e| RpcDispatchError::Model(format!("{member_name}: {e:?}")))?
648        {
649            dependencies.push(dep);
650        }
651    }
652    if let Some(default_member) = default_member {
653        builder = builder.default_case(
654            default_member,
655            TypeIdentifier::Primitive(PrimitiveKind::Byte),
656        );
657    }
658    let root = CompleteTypeObject::Union(builder.build_complete());
659    types
660        .resolve_synthetic(type_name, root, dependencies)
661        .map_err(|e| RpcDispatchError::Model(e.to_string()))
662}
663
664#[cfg(test)]
665#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
666mod tests {
667    use super::*;
668
669    fn idl_ast(idl: &str) -> Arc<Ast> {
670        Arc::new(zerodds_wire_probe::encode::model_from_idl(idl, "t.idl").unwrap())
671    }
672
673    fn leased_participant() -> (zerodds_test_domains::DomainLease, DomainParticipant) {
674        let lease = zerodds_test_domains::lease_domain();
675        let participant = zerodds_dcps::DomainParticipantFactory::instance()
676            .create_participant(lease.id(), zerodds_dcps::DomainParticipantQos::default())
677            .expect("participant");
678        (lease, participant)
679    }
680
681    #[test]
682    fn finds_a_declared_operation_across_flattened_services() {
683        let ast = idl_ast("interface Calculator { long add(in long a, in long b); };");
684        let (_lease, participant) = leased_participant();
685        let dispatcher = RpcDispatcher::new(ast, participant).expect("new");
686        assert!(dispatcher.knows_operation("add"));
687        assert!(!dispatcher.knows_operation("subtract"));
688    }
689
690    #[test]
691    fn an_operation_with_no_params_gets_a_dummy_member() {
692        let ast = idl_ast("interface Fyi { void ping(); };");
693        let (root, _deps) = build_synthetic_struct(&ast, "Fyi_ping::In", &[]).expect("build");
694        let CompleteTypeObject::Struct(s) = root else {
695            panic!("expected struct")
696        };
697        assert_eq!(s.member_seq.len(), 1);
698        assert_eq!(s.member_seq[0].detail.name, DUMMY_MEMBER);
699    }
700
701    #[test]
702    fn every_synthetic_type_is_final_not_appendable() {
703        let ast = idl_ast("interface Calculator { long add(in long a, in long b); };");
704        let members = vec![("a".to_string(), {
705            let svc = services(&ast).unwrap().into_iter().next().unwrap();
706            let op = all_operations_flattened(&ast, svc.id)
707                .unwrap()
708                .into_iter()
709                .next()
710                .unwrap();
711            op.params[0].type_
712        })];
713        let (root, _deps) = build_synthetic_struct(&ast, "Calculator_add::In", &members).unwrap();
714        let CompleteTypeObject::Struct(s) = root else {
715            panic!("expected struct")
716        };
717        use zerodds_types::type_object::flags::StructTypeFlag;
718        assert!(!s.struct_flags.has(StructTypeFlag::IS_APPENDABLE));
719        assert!(!s.struct_flags.has(StructTypeFlag::IS_MUTABLE));
720    }
721
722    #[test]
723    fn builds_one_interface_wide_call_and_return_union() {
724        let ast = idl_ast(
725            "interface Calculator { long add(in long a, in long b); void log(in string msg); };",
726        );
727        let (_lease, participant) = leased_participant();
728        let dispatcher = RpcDispatcher::new(Arc::clone(&ast), participant).expect("new");
729        let service = services(&ast).unwrap().into_iter().next().unwrap();
730        let model = dispatcher.model_for(&service).expect("model");
731
732        // Both operations share the SAME Call/Return union type name —
733        // one topic pair per interface, not one per operation.
734        assert_eq!(model.call_resolved.dynamic_type.name(), "Calculator_Call");
735        assert_eq!(model.call_resolved.dynamic_type.name(), "Calculator_Call");
736        assert_eq!(model.operations.len(), 2);
737
738        let add_disc = operation_hash("add");
739        let log_disc = operation_hash("log");
740        assert!(model.operations.contains_key(&add_disc));
741        assert!(model.operations.contains_key(&log_disc));
742        // Every operation has its Return-union case (§7.5.1.1.7 rule 5).
743        assert_eq!(
744            model.operations[&log_disc]
745                .result_resolved
746                .dynamic_type
747                .name(),
748            "Calculator_log_Result"
749        );
750        assert_eq!(
751            model.operations[&add_disc]
752                .result_resolved
753                .dynamic_type
754                .name(),
755            "Calculator_add_Result"
756        );
757    }
758}