Skip to main content

astarte_interfaces/mapping/datastream/
object.rs

1// This file is part of Astarte.
2//
3// Copyright 2025, 2026 SECO Mind Srl
4//
5// Licensed under the Apache License, Version 2.0 (the "License");
6// you may not use this file except in compliance with the License.
7// You may obtain a copy of the License at
8//
9//    http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing, software
12// distributed under the License is distributed on an "AS IS" BASIS,
13// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14// See the License for the specific language governing permissions and
15// limitations under the License.
16//
17// SPDX-License-Identifier: Apache-2.0
18
19//! Mappings for Datastream with object aggregation
20//!
21//! Data sent on an object interface is grouped and sent together in a single message.
22
23use std::cmp::Ordering;
24
25use crate::{
26    mapping::{
27        endpoint::{Endpoint, Level},
28        InterfaceMapping, MappingError,
29    },
30    schema::{Mapping, MappingType},
31    MappingPath,
32};
33
34/// The mapping of an object must have at least two components.
35///
36/// See <https://docs.astarte-platform.org/astarte/latest/030-interface.html#endpoints-and-aggregation>.
37pub const MIN_OBJECT_ENDPOINT_LEN: usize = 2;
38
39/// Shared struct for a mapping for all interface types.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct DatastreamObjectMapping {
42    pub(crate) endpoint: Endpoint<String>,
43    pub(crate) mapping_type: MappingType,
44    pub(crate) required: bool,
45    #[cfg(feature = "doc-fields")]
46    pub(crate) description: Option<String>,
47    #[cfg(feature = "doc-fields")]
48    pub(crate) doc: Option<String>,
49}
50
51impl DatastreamObjectMapping {
52    /// Flag to specify if the mapping is required.
53    ///
54    /// Object Aggregates mappings are optional by default, with this flag you can mark it as
55    /// required.
56    pub fn required(&self) -> bool {
57        self.required
58    }
59
60    /// Compares the object field with the mapping endpoint.
61    ///
62    /// Returns the ordering of the mappings.
63    pub fn cmp_object_field(&self, path: &str) -> Ordering {
64        let last = self.endpoint.last();
65
66        debug_assert!(
67            last.is_some(),
68            "an endpoint should always have at least an endpoint"
69        );
70
71        match last {
72            Some(Level::Simple(simple)) => simple.as_str().cmp(path),
73            Some(Level::Parameter(_)) => Ordering::Equal,
74            None => Ordering::Less,
75        }
76    }
77
78    /// Compares the object field with the mapping endpoint.
79    ///
80    /// Returns true if the last level is equal to the object field.
81    pub fn eq_object_field(&self, path: &str) -> bool {
82        let last = self.endpoint.last();
83
84        debug_assert!(
85            last.is_some(),
86            "an endpoint should always have at least an endpoint"
87        );
88
89        last.is_some_and(|endpoint_level| *endpoint_level == path)
90    }
91
92    pub(crate) fn is_object_path<'a>(&self, path: &MappingPath<'a>) -> bool {
93        // Must have the same size -1.
94        if self.endpoint.len().saturating_sub(1) != path.len() {
95            return false;
96        }
97
98        // This will skip the last one for the endpoint for the check above
99        self.endpoint
100            .iter()
101            .zip(path.levels.iter())
102            .all(|(endpoint_level, path_level)| endpoint_level == path_level)
103    }
104
105    /// Check that two endpoints are compatible with the same object.
106    ///
107    // https://docs.astarte-platform.org/astarte/latest/030-interface.html#endpoints-and-aggregation
108    pub(crate) fn is_same_object(&self, other: &DatastreamObjectMapping) -> bool {
109        if self.endpoint.len() != other.endpoint.len() {
110            return false;
111        }
112
113        // Iterate over the levels of the two endpoints, except the last one that is the object key.
114        self.endpoint
115            .iter()
116            .zip(other.endpoint.iter())
117            .rev()
118            .skip(1)
119            .all(|(level, other_level)| level == other_level)
120    }
121}
122
123impl InterfaceMapping for DatastreamObjectMapping {
124    fn endpoint(&self) -> &Endpoint<String> {
125        &self.endpoint
126    }
127
128    fn mapping_type(&self) -> MappingType {
129        self.mapping_type
130    }
131
132    #[cfg(feature = "doc-fields")]
133    #[cfg_attr(docsrs, doc(cfg(feature = "doc-fields")))]
134    fn description(&self) -> Option<&str> {
135        self.description.as_deref()
136    }
137
138    #[cfg(feature = "doc-fields")]
139    #[cfg_attr(docsrs, doc(cfg(feature = "doc-fields")))]
140    fn doc(&self) -> Option<&str> {
141        self.doc.as_deref()
142    }
143}
144
145impl<T> TryFrom<Mapping<T>> for DatastreamObjectMapping
146where
147    T: AsRef<str> + Into<String>,
148{
149    type Error = MappingError;
150
151    fn try_from(value: Mapping<T>) -> Result<Self, Self::Error> {
152        let endpoint = Endpoint::try_from(value.endpoint.as_ref())?;
153
154        if endpoint.len() < MIN_OBJECT_ENDPOINT_LEN {
155            return Err(MappingError::TooShortForObject(endpoint.to_string()));
156        }
157
158        Ok(Self {
159            endpoint,
160            mapping_type: value.mapping_type,
161            required: value.required.unwrap_or(false),
162            #[cfg(feature = "doc-fields")]
163            description: value.description.map(T::into),
164            #[cfg(feature = "doc-fields")]
165            doc: value.doc.map(T::into),
166        })
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use pretty_assertions::assert_eq;
173
174    use super::*;
175
176    fn mock_object_mapping(endpoint: &str) -> DatastreamObjectMapping {
177        DatastreamObjectMapping {
178            endpoint: endpoint.parse().unwrap(),
179            mapping_type: MappingType::Boolean,
180            required: false,
181            #[cfg(feature = "doc-fields")]
182            description: None,
183            #[cfg(feature = "doc-fields")]
184            doc: None,
185        }
186    }
187
188    #[test]
189    fn getters_success() {
190        let description = Some("Object mapping description");
191        let doc = Some("Object mapping doc");
192        let mapping_type = MappingType::Boolean;
193        let mapping = Mapping {
194            endpoint: "/object/path",
195            mapping_type,
196            reliability: None,
197            explicit_timestamp: None,
198            retention: None,
199            expiry: None,
200            database_retention_policy: None,
201            database_retention_ttl: None,
202            allow_unset: None,
203            required: None,
204            description,
205            doc,
206        };
207
208        let obj_mapping = DatastreamObjectMapping::try_from(mapping).unwrap();
209
210        let exp = Endpoint::try_from("/object/path").unwrap();
211        assert_eq!(*obj_mapping.endpoint(), exp);
212        assert_eq!(obj_mapping.mapping_type(), mapping_type);
213        #[cfg(feature = "doc-fields")]
214        {
215            assert_eq!(description, obj_mapping.description());
216            assert_eq!(doc, obj_mapping.doc());
217        }
218    }
219
220    #[test]
221    fn mapping_error_to_short() {
222        let mapping = Mapping {
223            endpoint: "/tooShort",
224            mapping_type: MappingType::Boolean,
225            reliability: None,
226            explicit_timestamp: None,
227            retention: None,
228            expiry: None,
229            database_retention_policy: None,
230            database_retention_ttl: None,
231            allow_unset: None,
232            required: None,
233            description: None,
234            doc: None,
235        };
236
237        let err = DatastreamObjectMapping::try_from(mapping).unwrap_err();
238        assert!(matches!(err, MappingError::TooShortForObject(_)));
239    }
240
241    #[test]
242    fn check_object_path() {
243        let endpoint = mock_object_mapping("/%{sensor_id}/boolean_endpoint");
244
245        let path = MappingPath::try_from("/1/boolean_endpoint").unwrap();
246
247        assert!(!endpoint.is_object_path(&path));
248
249        let path = MappingPath::try_from("/1").unwrap();
250        assert!(endpoint.is_object_path(&path));
251    }
252
253    #[test]
254    fn object_field() {
255        let endpoint = mock_object_mapping("/%{sensor_id}/boolean_endpoint");
256
257        assert!(endpoint.eq_object_field("boolean_endpoint"));
258        assert!(!endpoint.eq_object_field("foo"));
259    }
260
261    #[test]
262    fn same_object() {
263        let endpoint = mock_object_mapping("/base/foo");
264        let same = mock_object_mapping("/base/bar");
265        let different = mock_object_mapping("/other/bar");
266
267        assert!(endpoint.is_same_object(&same));
268        assert!(!endpoint.is_same_object(&different));
269    }
270}