Skip to main content

zerodds_rpc/
service_mapping.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Service IDL → typed service data model — Spec §7.4.
5//!
6//! The DDS-RPC spec maps an IDL service definition
7//!
8//! ```text
9//! @service interface Calculator {
10//!     long add(in long a, in long b);
11//!     oneway void log(in string msg);
12//! };
13//! ```
14//!
15//! onto two wire structures per method:
16//!
17//! * `<Service>_<Method>_In`  — request payload (all `in`/`inout`).
18//! * `<Service>_<Method>_Out` — reply payload (return + `out`/`inout`).
19//!
20//! This foundation stage (C6.1.A) represents only the **data model**
21//! ([`ServiceDef`], [`MethodDef`], [`ParamDef`]). The actual
22//! codegen stage (C6.1.B) consumes the model and emits IDL
23//! structures + Rust bindings.
24//!
25//! The model is constructed via [`lower_service`] from a `zerodds_idl::ast::
26//! InterfaceDef` plus the already-typed RPC annotations
27//! ([`crate::annotations::LoweredRpc`]). Validation at
28//! this stage:
29//!
30//! * The service name is non-empty + alphanumeric + `_`.
31//! * Method names are unique.
32//! * Parameter names per method are unique.
33//! * `oneway` methods have a `void` return and no `out`/`inout` params.
34
35extern crate alloc;
36
37use alloc::string::{String, ToString};
38use alloc::vec::Vec;
39
40use zerodds_idl::ast::{Export, InterfaceDef, OpDecl, ParamAttribute, TypeSpec};
41
42use crate::annotations::{LoweredRpc, lower_rpc_annotations};
43use crate::error::{RpcError, RpcResult};
44use crate::topic_naming::{ServiceTopicNames, validate_service_name};
45
46/// Direction attribute of an RPC parameter.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ParamDirection {
49    /// `in` — Request-only.
50    In,
51    /// `out` — Reply-only.
52    Out,
53    /// `inout` — beides.
54    InOut,
55}
56
57impl From<ParamAttribute> for ParamDirection {
58    fn from(value: ParamAttribute) -> Self {
59        match value {
60            ParamAttribute::In => Self::In,
61            ParamAttribute::Out => Self::Out,
62            ParamAttribute::InOut => Self::InOut,
63        }
64    }
65}
66
67impl ParamDirection {
68    /// `true` if the parameter lands in the request topic (`in` or `inout`).
69    #[must_use]
70    pub const fn is_in(self) -> bool {
71        matches!(self, Self::In | Self::InOut)
72    }
73
74    /// `true` if the parameter lands in the reply topic (`out` or `inout`).
75    #[must_use]
76    pub const fn is_out(self) -> bool {
77        matches!(self, Self::Out | Self::InOut)
78    }
79}
80
81/// Type reference in the service model. At the foundation stage this is only a
82/// re-use of the AST `TypeSpec` — phase C6.1.B can insert a resolved
83/// type object here.
84pub type TypeRef = TypeSpec;
85
86/// Ein RPC-Parameter.
87#[derive(Debug, Clone, PartialEq)]
88pub struct ParamDef {
89    /// Parameter-Name.
90    pub name: String,
91    /// Direction (`in`/`out`/`inout`).
92    pub direction: ParamDirection,
93    /// Typ.
94    pub type_ref: TypeRef,
95}
96
97/// An RPC method.
98#[derive(Debug, Clone, PartialEq)]
99pub struct MethodDef {
100    /// Method name.
101    pub name: String,
102    /// Parameters in declaration order.
103    pub params: Vec<ParamDef>,
104    /// Return type. `None` for `void`.
105    pub return_type: Option<TypeRef>,
106    /// `true` if `oneway` (no reply, only `in` params, void return).
107    pub oneway: bool,
108}
109
110impl MethodDef {
111    /// All `in`/`inout` parameters (request fields).
112    pub fn in_params(&self) -> impl Iterator<Item = &ParamDef> {
113        self.params.iter().filter(|p| p.direction.is_in())
114    }
115
116    /// All `out`/`inout` parameters (reply fields).
117    pub fn out_params(&self) -> impl Iterator<Item = &ParamDef> {
118        self.params.iter().filter(|p| p.direction.is_out())
119    }
120}
121
122/// An RPC service definition.
123#[derive(Debug, Clone, PartialEq)]
124pub struct ServiceDef {
125    /// Effective service name (`@service(name="...")` or interface name).
126    pub name: String,
127    /// Methods.
128    pub methods: Vec<MethodDef>,
129}
130
131impl ServiceDef {
132    /// Returns the associated topic names (`<svc>_Request` / `<svc>_Reply`).
133    ///
134    /// # Errors
135    /// Should not happen — `lower_service` already validates the name.
136    /// Propagated defensively nonetheless.
137    pub fn topic_names(&self) -> RpcResult<ServiceTopicNames> {
138        ServiceTopicNames::new(&self.name)
139    }
140}
141
142/// Lowers an IDL service definition into the typed service model.
143///
144/// Expects that the interface's annotations have already been
145/// pre-lowered via [`lower_rpc_annotations`]. If `@service` is not
146/// set, the interface name is used as the service name.
147///
148/// # Errors
149/// * `RpcError::InvalidServiceName` for an empty or invalid name.
150/// * `RpcError::InvalidMethodName` for an empty method name.
151/// * `RpcError::OnewayWithReturn` / `OnewayWithOutParam` on
152///   inkonsistenten oneway-Deklarationen.
153/// * `RpcError::DuplicateMethod` / `DuplicateParam` on name collisions.
154pub fn lower_service(iface: &InterfaceDef, lowered: &LoweredRpc) -> RpcResult<ServiceDef> {
155    // Service name from @service(name="...") or the interface name.
156    let svc_name = lowered
157        .service_name()
158        .map(ToString::to_string)
159        .unwrap_or_else(|| iface.name.text.clone());
160    validate_service_name(&svc_name)?;
161
162    let mut methods = Vec::new();
163    for export in &iface.exports {
164        if let Export::Op(op) = export {
165            methods.push(lower_method(op)?);
166        }
167        // Other exports (Attr/Type/Const/Except) are explicitly
168        // out-of-scope in C6.1.A — exceptions are modeled in C6.1.B as
169        // RemoteException carriers.
170    }
171
172    // Duplicate-Method-Check.
173    for i in 0..methods.len() {
174        for j in (i + 1)..methods.len() {
175            if methods[i].name == methods[j].name {
176                return Err(RpcError::DuplicateMethod(methods[i].name.clone()));
177            }
178        }
179    }
180
181    Ok(ServiceDef {
182        name: svc_name,
183        methods,
184    })
185}
186
187fn lower_method(op: &OpDecl) -> RpcResult<MethodDef> {
188    let name = op.name.text.clone();
189    if name.is_empty() {
190        return Err(RpcError::InvalidMethodName(name));
191    }
192
193    // Annotation lowering of the method annotations: `@oneway` in
194    // annotation form must be treated equivalently to the AST `oneway`
195    // keyword.
196    let method_anns = lower_rpc_annotations(&op.annotations);
197    let oneway = op.oneway || method_anns.has_oneway();
198
199    let return_type = op.return_type.clone();
200
201    if oneway && return_type.is_some() {
202        return Err(RpcError::OnewayWithReturn(name));
203    }
204
205    let mut params = Vec::with_capacity(op.params.len());
206    for p in &op.params {
207        // Param annotation `@in/@out/@inout` overrides the native
208        // ParamAttribute, if explicitly set.
209        let p_anns = lower_rpc_annotations(&p.annotations);
210        let direction = override_direction(p.attribute, &p_anns);
211
212        if oneway && direction.is_out() {
213            return Err(RpcError::OnewayWithOutParam {
214                method: name.clone(),
215                param: p.name.text.clone(),
216            });
217        }
218
219        params.push(ParamDef {
220            name: p.name.text.clone(),
221            direction,
222            type_ref: p.type_spec.clone(),
223        });
224    }
225
226    // Duplicate-Param-Check.
227    for i in 0..params.len() {
228        for j in (i + 1)..params.len() {
229            if params[i].name == params[j].name {
230                return Err(RpcError::DuplicateParam {
231                    method: name,
232                    param: params[i].name.clone(),
233                });
234            }
235        }
236    }
237
238    Ok(MethodDef {
239        name,
240        params,
241        return_type,
242        oneway,
243    })
244}
245
246fn override_direction(native: ParamAttribute, anns: &LoweredRpc) -> ParamDirection {
247    use crate::annotations::RpcAnnotation;
248    for a in &anns.builtins {
249        match a {
250            RpcAnnotation::In => return ParamDirection::In,
251            RpcAnnotation::Out => return ParamDirection::Out,
252            RpcAnnotation::InOut => return ParamDirection::InOut,
253            _ => {}
254        }
255    }
256    native.into()
257}
258
259#[cfg(test)]
260#[allow(clippy::unwrap_used, clippy::expect_used)]
261mod tests {
262    use super::*;
263    use zerodds_idl::ast::{
264        Annotation, AnnotationParams, Identifier, IntegerType, InterfaceKind, OpDecl, ParamDecl,
265        PrimitiveType, ScopedName, StringType, TypeSpec,
266    };
267    use zerodds_idl::errors::Span;
268
269    fn sp() -> Span {
270        Span::SYNTHETIC
271    }
272
273    fn ident(t: &str) -> Identifier {
274        Identifier::new(t, sp())
275    }
276
277    fn long_t() -> TypeSpec {
278        TypeSpec::Primitive(PrimitiveType::Integer(IntegerType::Long))
279    }
280
281    fn string_t() -> TypeSpec {
282        TypeSpec::String(StringType {
283            wide: false,
284            bound: None,
285            span: sp(),
286        })
287    }
288
289    fn op(
290        name: &str,
291        oneway: bool,
292        ret: Option<TypeSpec>,
293        params: Vec<ParamDecl>,
294        anns: Vec<Annotation>,
295    ) -> OpDecl {
296        OpDecl {
297            name: ident(name),
298            oneway,
299            return_type: ret,
300            params,
301            raises: Vec::new(),
302            context: Vec::new(),
303            annotations: anns,
304            span: sp(),
305        }
306    }
307
308    fn param(name: &str, attr: ParamAttribute, ty: TypeSpec) -> ParamDecl {
309        ParamDecl {
310            attribute: attr,
311            type_spec: ty,
312            name: ident(name),
313            annotations: Vec::new(),
314            span: sp(),
315        }
316    }
317
318    fn iface(name: &str, exports: Vec<Export>, anns: Vec<Annotation>) -> InterfaceDef {
319        InterfaceDef {
320            kind: InterfaceKind::Plain,
321            name: ident(name),
322            bases: Vec::new(),
323            exports,
324            annotations: anns,
325            span: sp(),
326        }
327    }
328
329    fn ann_simple(name: &str) -> Annotation {
330        Annotation {
331            name: ScopedName {
332                absolute: false,
333                parts: vec![ident(name)],
334                span: sp(),
335            },
336            params: AnnotationParams::None,
337            span: sp(),
338        }
339    }
340
341    #[test]
342    fn calculator_service_with_in_params_lowers() {
343        let add = op(
344            "add",
345            false,
346            Some(long_t()),
347            vec![
348                param("a", ParamAttribute::In, long_t()),
349                param("b", ParamAttribute::In, long_t()),
350            ],
351            Vec::new(),
352        );
353        let i = iface(
354            "Calculator",
355            vec![Export::Op(add)],
356            vec![ann_simple("service")],
357        );
358        let lowered = lower_rpc_annotations(&i.annotations);
359        let svc = lower_service(&i, &lowered).unwrap();
360        assert_eq!(svc.name, "Calculator");
361        assert_eq!(svc.methods.len(), 1);
362        let m = &svc.methods[0];
363        assert_eq!(m.name, "add");
364        assert!(!m.oneway);
365        assert_eq!(m.params.len(), 2);
366        assert_eq!(m.in_params().count(), 2);
367        assert_eq!(m.out_params().count(), 0);
368        assert_eq!(svc.topic_names().unwrap().request, "Calculator_Request");
369    }
370
371    #[test]
372    fn oneway_method_with_return_is_error() {
373        let bad = op(
374            "log",
375            true,
376            Some(long_t()),
377            vec![param("msg", ParamAttribute::In, string_t())],
378            Vec::new(),
379        );
380        let i = iface("Logger", vec![Export::Op(bad)], Vec::new());
381        let lowered = lower_rpc_annotations(&i.annotations);
382        let err = lower_service(&i, &lowered).unwrap_err();
383        assert!(matches!(err, RpcError::OnewayWithReturn(_)));
384    }
385
386    #[test]
387    fn oneway_method_with_out_param_is_error() {
388        let bad = op(
389            "fire",
390            true,
391            None,
392            vec![param("result", ParamAttribute::Out, long_t())],
393            Vec::new(),
394        );
395        let i = iface("Svc", vec![Export::Op(bad)], Vec::new());
396        let lowered = lower_rpc_annotations(&i.annotations);
397        let err = lower_service(&i, &lowered).unwrap_err();
398        assert!(matches!(err, RpcError::OnewayWithOutParam { .. }));
399    }
400
401    #[test]
402    fn oneway_method_with_inout_param_is_error() {
403        let bad = op(
404            "fire",
405            true,
406            None,
407            vec![param("v", ParamAttribute::InOut, long_t())],
408            Vec::new(),
409        );
410        let i = iface("Svc", vec![Export::Op(bad)], Vec::new());
411        let lowered = lower_rpc_annotations(&i.annotations);
412        let err = lower_service(&i, &lowered).unwrap_err();
413        assert!(matches!(err, RpcError::OnewayWithOutParam { .. }));
414    }
415
416    #[test]
417    fn oneway_with_only_in_params_lowers() {
418        let m = op(
419            "log",
420            true,
421            None,
422            vec![param("msg", ParamAttribute::In, string_t())],
423            Vec::new(),
424        );
425        let i = iface("Logger", vec![Export::Op(m)], Vec::new());
426        let lowered = lower_rpc_annotations(&i.annotations);
427        let svc = lower_service(&i, &lowered).unwrap();
428        assert!(svc.methods[0].oneway);
429        assert_eq!(svc.methods[0].in_params().count(), 1);
430        assert_eq!(svc.methods[0].out_params().count(), 0);
431    }
432
433    #[test]
434    fn oneway_via_annotation_recognized() {
435        // Native oneway=false, but @oneway annotation set.
436        let m = op("ping", false, None, Vec::new(), vec![ann_simple("oneway")]);
437        let i = iface("Svc", vec![Export::Op(m)], Vec::new());
438        let lowered = lower_rpc_annotations(&i.annotations);
439        let svc = lower_service(&i, &lowered).unwrap();
440        assert!(svc.methods[0].oneway);
441    }
442
443    #[test]
444    fn duplicate_method_detected() {
445        let m1 = op("foo", false, None, Vec::new(), Vec::new());
446        let m2 = op("foo", false, None, Vec::new(), Vec::new());
447        let i = iface("Svc", vec![Export::Op(m1), Export::Op(m2)], Vec::new());
448        let lowered = lower_rpc_annotations(&i.annotations);
449        let err = lower_service(&i, &lowered).unwrap_err();
450        assert_eq!(err, RpcError::DuplicateMethod("foo".into()));
451    }
452
453    #[test]
454    fn duplicate_param_detected() {
455        let m = op(
456            "add",
457            false,
458            Some(long_t()),
459            vec![
460                param("x", ParamAttribute::In, long_t()),
461                param("x", ParamAttribute::In, long_t()),
462            ],
463            Vec::new(),
464        );
465        let i = iface("Svc", vec![Export::Op(m)], Vec::new());
466        let lowered = lower_rpc_annotations(&i.annotations);
467        let err = lower_service(&i, &lowered).unwrap_err();
468        assert!(matches!(err, RpcError::DuplicateParam { .. }));
469    }
470
471    #[test]
472    fn empty_method_name_rejected() {
473        let m = op("", false, None, Vec::new(), Vec::new());
474        let i = iface("Svc", vec![Export::Op(m)], Vec::new());
475        let lowered = lower_rpc_annotations(&i.annotations);
476        let err = lower_service(&i, &lowered).unwrap_err();
477        assert!(matches!(err, RpcError::InvalidMethodName(_)));
478    }
479
480    #[test]
481    fn invalid_service_name_rejected() {
482        let i = iface("Bad-Name", Vec::new(), Vec::new());
483        let lowered = lower_rpc_annotations(&i.annotations);
484        let err = lower_service(&i, &lowered).unwrap_err();
485        assert!(matches!(err, RpcError::InvalidServiceName(_)));
486    }
487
488    #[test]
489    fn service_name_from_annotation_overrides_iface_name() {
490        // @service(name="OuterName") wins over the interface name.
491        let i = iface(
492            "InternalIface",
493            Vec::new(),
494            vec![Annotation {
495                name: ScopedName {
496                    absolute: false,
497                    parts: vec![ident("service")],
498                    span: sp(),
499                },
500                params: AnnotationParams::Named(vec![zerodds_idl::ast::NamedParam {
501                    name: ident("name"),
502                    value: zerodds_idl::ast::ConstExpr::Literal(zerodds_idl::ast::Literal {
503                        kind: zerodds_idl::ast::LiteralKind::String,
504                        raw: "\"OuterName\"".into(),
505                        span: sp(),
506                    }),
507                    span: sp(),
508                }]),
509                span: sp(),
510            }],
511        );
512        let lowered = lower_rpc_annotations(&i.annotations);
513        let svc = lower_service(&i, &lowered).unwrap();
514        assert_eq!(svc.name, "OuterName");
515    }
516
517    #[test]
518    fn inout_param_appears_in_both_directions() {
519        let m = op(
520            "swap",
521            false,
522            None,
523            vec![param("v", ParamAttribute::InOut, long_t())],
524            Vec::new(),
525        );
526        let i = iface("Svc", vec![Export::Op(m)], Vec::new());
527        let lowered = lower_rpc_annotations(&i.annotations);
528        let svc = lower_service(&i, &lowered).unwrap();
529        let m = &svc.methods[0];
530        assert_eq!(m.in_params().count(), 1);
531        assert_eq!(m.out_params().count(), 1);
532    }
533
534    #[test]
535    fn out_only_param_is_reply_only() {
536        let m = op(
537            "result",
538            false,
539            None,
540            vec![param("v", ParamAttribute::Out, long_t())],
541            Vec::new(),
542        );
543        let i = iface("Svc", vec![Export::Op(m)], Vec::new());
544        let lowered = lower_rpc_annotations(&i.annotations);
545        let svc = lower_service(&i, &lowered).unwrap();
546        let m = &svc.methods[0];
547        assert_eq!(m.in_params().count(), 0);
548        assert_eq!(m.out_params().count(), 1);
549    }
550
551    #[test]
552    fn param_annotation_in_overrides_native_attr() {
553        // ParamAttribute::Out, but @in annotation -> @in wins.
554        let mut p = param("v", ParamAttribute::Out, long_t());
555        p.annotations.push(ann_simple("in"));
556        let m = op("foo", false, None, vec![p], Vec::new());
557        let i = iface("Svc", vec![Export::Op(m)], Vec::new());
558        let lowered = lower_rpc_annotations(&i.annotations);
559        let svc = lower_service(&i, &lowered).unwrap();
560        assert_eq!(svc.methods[0].params[0].direction, ParamDirection::In);
561    }
562
563    #[test]
564    fn non_op_exports_are_ignored() {
565        // Const exports should not disturb the service model — they
566        // are explicitly not represented in C6.1.A.
567        let const_decl = zerodds_idl::ast::ConstDecl {
568            name: ident("MAX"),
569            type_: zerodds_idl::ast::ConstType::Integer(IntegerType::Long),
570            value: zerodds_idl::ast::ConstExpr::Literal(zerodds_idl::ast::Literal {
571                kind: zerodds_idl::ast::LiteralKind::Integer,
572                raw: "10".into(),
573                span: sp(),
574            }),
575            annotations: Vec::new(),
576            span: sp(),
577        };
578        let m = op("foo", false, None, Vec::new(), Vec::new());
579        let i = iface(
580            "Svc",
581            vec![Export::Const(const_decl), Export::Op(m)],
582            Vec::new(),
583        );
584        let lowered = lower_rpc_annotations(&i.annotations);
585        let svc = lower_service(&i, &lowered).unwrap();
586        assert_eq!(svc.methods.len(), 1);
587    }
588
589    #[test]
590    fn param_direction_helpers() {
591        assert!(ParamDirection::In.is_in());
592        assert!(!ParamDirection::In.is_out());
593        assert!(!ParamDirection::Out.is_in());
594        assert!(ParamDirection::Out.is_out());
595        assert!(ParamDirection::InOut.is_in());
596        assert!(ParamDirection::InOut.is_out());
597    }
598
599    #[test]
600    fn param_direction_from_param_attribute() {
601        assert_eq!(ParamDirection::from(ParamAttribute::In), ParamDirection::In);
602        assert_eq!(
603            ParamDirection::from(ParamAttribute::Out),
604            ParamDirection::Out
605        );
606        assert_eq!(
607            ParamDirection::from(ParamAttribute::InOut),
608            ParamDirection::InOut
609        );
610    }
611}