Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! NGSI-LD data model (ETSI CIM 009 V1.9.1).
3 : //!
4 : //! Shapes and invariants only: no I/O, no clocks, no config.
5 : #![cfg_attr(not(test), warn(clippy::expect_used))]
6 : #![deny(missing_docs)]
7 :
8 : pub mod error;
9 : pub mod id;
10 : pub mod operations;
11 :
12 : pub use error::{NgsiError, ProblemDetails};
13 : pub use id::{EntityId, TenantId};
14 :
15 : /// API root path (CIM 009 clause 6.2).
16 : pub const API_ROOT: &str = "/ngsi-ld/v1";
17 :
18 : /// Egress key order for a served payload: every object serializes `id`
19 : /// then `type` first, recursively (an attribute object leads with
20 : /// `"type": "Property"`, a GeoJSON Feature with `id`/`type` — the order the
21 : /// spec's own examples print). Cosmetic only: RFC 8259 objects are unordered
22 : /// and CIM 009 4.5.1 mandates presence, not position. Applied ONLY at egress
23 : /// (responses and notifications) — internal serialization (storage, temporal
24 : /// diff) stays byte-stable alphabetical and must not use this.
25 : pub struct SpecOrder<'a>(pub &'a serde_json::Value);
26 :
27 : impl serde::Serialize for SpecOrder<'_> {
28 116404 : fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
29 : use serde::ser::SerializeMap;
30 : use serde_json::Value;
31 116404 : match self.0 {
32 28843 : Value::Object(m) => {
33 28843 : let mut map = s.serialize_map(Some(m.len()))?;
34 57686 : for k in ["id", "type"] {
35 57686 : if let Some(v) = m.get(k) {
36 39382 : map.serialize_entry(k, &SpecOrder(v))?;
37 18304 : }
38 : }
39 102774 : for (k, v) in m {
40 102774 : if k != "id" && k != "type" {
41 63392 : map.serialize_entry(k, &SpecOrder(v))?;
42 39382 : }
43 : }
44 28843 : map.end()
45 : }
46 5136 : Value::Array(a) => s.collect_seq(a.iter().map(SpecOrder)),
47 82425 : other => other.serialize(s),
48 : }
49 116404 : }
50 : }
51 :
52 : /// Serialize a response or notification payload in egress key order
53 : /// (serializing a `Value` cannot fail).
54 1660 : pub fn ordered_vec(v: &serde_json::Value) -> Vec<u8> {
55 1660 : serde_json::to_vec(&SpecOrder(v)).unwrap_or_default()
56 1660 : }
57 :
58 : /// 5.2.4 Entity, Table 5.2.4-1, with the common members of Table 5.2.2-1:
59 : /// the members of an Entity document that are not Attributes. Every other
60 : /// member is a Property or a Relationship (`location` and the two other
61 : /// default GeoProperties included), so this list is what every layer that
62 : /// has to tell an attribute from an entity member reads — the query
63 : /// projection, the notification diff, the temporal split, the outbox event.
64 : /// A layer with its own copy is a layer that will disagree with the others
65 : /// about what an attribute is.
66 : ///
67 : /// `@context` is in the list because a stored or rendered document carries
68 : /// it and it is not an Attribute either. It is the one member Table 5.2.4-1
69 : /// does not name, which is why [`is_meta`] — the Entity's OWN members, the
70 : /// question 5.2.4 asks — leaves it out.
71 : pub const ENTITY_META_KEYS: &[&str] = &[
72 : "id",
73 : "type",
74 : "scope",
75 : "createdAt",
76 : "modifiedAt",
77 : "deletedAt",
78 : "expiresAt",
79 : "@context",
80 : ];
81 :
82 : /// The members of an Entity that are not Attributes — `id`, `type`,
83 : /// `scope`, `expiresAt`, `createdAt`, `modifiedAt`, `deletedAt`. See
84 : /// [`ENTITY_META_KEYS`] for the document-level list this narrows.
85 129768 : pub fn is_meta(k: &str) -> bool {
86 129768 : k != "@context" && ENTITY_META_KEYS.contains(&k)
87 129768 : }
88 :
89 : /// Canonical lexicographic comparison key for a 4.6.3 DateTime: the trailing
90 : /// `Z` dropped and the optional seconds fraction (`.` or the request-side `,`
91 : /// separator) zero-padded to six digits, so string order equals temporal
92 : /// order across spellings of the same instant. Non-DateTime input is
93 : /// returned as-is (callers validated at write/parse time).
94 18380 : pub fn dt_key(s: &str) -> String {
95 18380 : let Some(body) = s.strip_suffix('Z') else {
96 50 : return s.to_owned();
97 : };
98 18330 : if !body.is_char_boundary(19) {
99 6 : return s.to_owned();
100 18324 : }
101 18324 : let (base, frac) = body.split_at(19);
102 18324 : let digits = frac
103 18324 : .strip_prefix('.')
104 18324 : .or_else(|| frac.strip_prefix(','))
105 18324 : .unwrap_or("");
106 18324 : format!("{base}.{digits:0<6}")
107 18380 : }
108 :
109 : /// One ISO 8601 duration — `P[nY][nM][nW][nD][T[nH][nM][nS]]` — read into
110 : /// its components. Three NGSI-LD members carry this syntax and weigh it
111 : /// differently: a Context Source registration's refresh rate (5.2.9) and an
112 : /// EntityMap's `entityMapLifetime` (Table 6.4.3.2-1) want a span in seconds,
113 : /// where a month is a nominal thirty days, while a temporal aggregation
114 : /// period (4.5.19) keeps months as calendar months because that is what its
115 : /// buckets are cut on. The scan is one function; the weighing belongs to
116 : /// the caller.
117 : #[derive(Debug, Clone, Copy, Default, PartialEq)]
118 : pub struct IsoDuration {
119 : /// `nY`.
120 : pub years: f64,
121 : /// `nM` before the `T` — calendar months.
122 : pub months: f64,
123 : /// `nW`.
124 : pub weeks: f64,
125 : /// `nD`.
126 : pub days: f64,
127 : /// `nH`.
128 : pub hours: f64,
129 : /// `nM` after the `T`.
130 : pub minutes: f64,
131 : /// `nS`.
132 : pub seconds: f64,
133 : /// Every component present is a plain digit run within `i64`: no
134 : /// fraction, no magnitude a whole-second span could not hold.
135 : pub whole: bool,
136 : /// No component at all — a bare `P`.
137 : pub empty: bool,
138 : }
139 :
140 : /// Read the syntax, in the designator order ISO 8601 fixes. `None` for
141 : /// anything that is not it: a missing `P`, a component with no digit, a
142 : /// designator out of order, repeated or in the wrong half, a number that
143 : /// does not parse, digits with no designator to weigh them, or a `T` with
144 : /// no time component after it.
145 460 : pub fn parse_iso_duration(s: &str) -> Option<IsoDuration> {
146 : /// One half of the duration — the date designators or the time ones.
147 : /// `out` takes the values in `units` order; the answer is whether the
148 : /// half carried anything.
149 580 : fn scan(part: &str, units: &[char], out: &mut [f64], whole: &mut bool) -> Option<bool> {
150 580 : let mut p = part;
151 : // each designator is read at most once and in order: the search for
152 : // the next one starts after the last one matched
153 580 : let mut next = 0usize;
154 580 : let mut any = false;
155 914 : while !p.is_empty() {
156 1444 : let i = p.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == ','))?;
157 448 : let (num, rest) = p.split_at(i);
158 448 : let unit = rest.chars().next()?;
159 1108 : let slot = units.iter().skip(next).position(|u| *u == unit)? + next;
160 354 : next = slot + 1;
161 364 : if !num.bytes().any(|b| b.is_ascii_digit()) {
162 18 : return None;
163 336 : }
164 336 : *whole &= num.parse::<i64>().is_ok();
165 : // 4.6.3 leaves the fraction separator open, here as everywhere
166 336 : let value: f64 = num.replace(',', ".").parse().ok()?;
167 334 : if !value.is_finite() {
168 0 : return None;
169 334 : }
170 334 : out[slot] = value;
171 334 : any = true;
172 334 : p = &rest[unit.len_utf8()..];
173 : }
174 446 : Some(any)
175 580 : }
176 :
177 460 : let rest = s.strip_prefix('P')?;
178 376 : let (date, time) = match rest.split_once('T') {
179 204 : Some((d, t)) => (d, Some(t)),
180 172 : None => (rest, None),
181 : };
182 376 : let mut v = [0f64; 7];
183 376 : let mut whole = true;
184 376 : let date_any = scan(date, &['Y', 'M', 'W', 'D'], &mut v[..4], &mut whole)?;
185 278 : let time_any = match time {
186 74 : None => false,
187 204 : Some(t) => {
188 : // a `T` with nothing to designate is not a duration
189 204 : if !scan(t, &['H', 'M', 'S'], &mut v[4..], &mut whole)? {
190 20 : return None;
191 148 : }
192 148 : true
193 : }
194 : };
195 : Some(IsoDuration {
196 222 : years: v[0],
197 222 : months: v[1],
198 222 : weeks: v[2],
199 222 : days: v[3],
200 222 : hours: v[4],
201 222 : minutes: v[5],
202 222 : seconds: v[6],
203 222 : whole,
204 222 : empty: !date_any && !time_any,
205 : })
206 460 : }
207 :
208 : /// Attribute names in paths must be valid terms/IRIs (4.6.2) — 400 otherwise.
209 460 : pub fn check_attr_name(attr: &str) -> Result<(), NgsiError> {
210 : // 4.6.2 supported names: no '@' (keyword territory), no parens/quotes/etc.
211 460 : let ok = !attr.is_empty()
212 460 : && attr
213 460 : .chars()
214 2630 : .all(|c| c.is_ascii_alphanumeric() || "_:.#/%-+".contains(c))
215 460 : && !has_dot_segment(attr);
216 460 : if ok {
217 380 : Ok(())
218 : } else {
219 80 : Err(NgsiError::BadRequestData(format!(
220 80 : "invalid attribute name {attr:?}"
221 80 : )))
222 : }
223 460 : }
224 :
225 : /// A 4.6.2 name begins with a letter, so no valid Attribute name is a relative
226 : /// path dot-segment (RFC 3986 clause 5.2.4). The name is interpolated into the
227 : /// request URLs of forwarded operations, where a `.`/`..` segment addresses a
228 : /// different resource of the registration endpoint — `/entities/{id}/attrs/..`
229 : /// is that endpoint's Entity resource, and a URL parser resolves the segment
230 : /// before the request leaves this process. Percent triplets are folded once
231 : /// first, because the endpoint decodes the path it is given.
232 : ///
233 : /// The name a client sends is checked by [`check_attr_name`], but that is not
234 : /// the only form that reaches a path: 4.3.6.6 compacts the name again with a
235 : /// registered `@context`, which is client-supplied and may bind any term. The
236 : /// compacted form is held to this same rule before it is written into a
237 : /// forwarded URL.
238 466 : pub fn has_dot_segment(attr: &str) -> bool {
239 466 : attr.to_ascii_lowercase()
240 466 : .replace("%2e", ".")
241 466 : .replace("%2f", "/")
242 466 : .split('/')
243 554 : .any(|seg| seg == "." || seg == "..")
244 466 : }
245 :
246 : #[cfg(test)]
247 : mod tests {
248 : use super::dt_key;
249 : #[test]
250 2 : fn meta_members_are_the_non_attribute_members_of_an_entity() {
251 14 : for k in [
252 2 : "id",
253 2 : "type",
254 2 : "scope",
255 2 : "createdAt",
256 2 : "modifiedAt",
257 2 : "deletedAt",
258 2 : "expiresAt",
259 2 : ] {
260 14 : assert!(super::is_meta(k), "{k}");
261 : }
262 20 : for k in [
263 2 : "",
264 2 : "v",
265 2 : "Type",
266 2 : "location",
267 2 : "observationSpace",
268 2 : "operationSpace",
269 2 : "speed",
270 2 : "@context",
271 2 : "observedAt",
272 2 : "datasetId",
273 2 : ] {
274 20 : assert!(!super::is_meta(k), "{k}");
275 : }
276 2 : }
277 :
278 : /// 4.6.3 DateTime: only a DateTime has a canonical key — anything else
279 : /// is returned unchanged, including a multi-byte string that ends in
280 : /// `Z` and is long enough to reach the seconds position in bytes.
281 : #[test]
282 2 : fn non_datetime_input_is_returned_unchanged() {
283 10 : for s in ["", "Z", "not-a-date", "ααααααααααZ", "urn:ngsi-ld:nullZ"] {
284 10 : assert_eq!(dt_key(s), s, "{s:?}");
285 : }
286 : // a real DateTime still normalizes to its comparison key
287 2 : assert_eq!(dt_key("2026-05-01T00:00:00Z"), "2026-05-01T00:00:00.000000");
288 2 : assert_eq!(
289 2 : dt_key("2026-05-01T00:00:00,5Z"),
290 : "2026-05-01T00:00:00.500000"
291 : );
292 2 : }
293 :
294 : /// One scan serves three weighings, so the syntax it accepts is the
295 : /// syntax all three accept: designators in ISO order, each at most once
296 : /// and in its own half, every component a number with a digit.
297 : #[test]
298 2 : fn a_duration_is_read_into_its_components() {
299 2 : let d = super::parse_iso_duration("P3Y6M4WT12H30M5.5S").expect("a duration");
300 2 : assert_eq!((d.years, d.months, d.weeks, d.days), (3.0, 6.0, 4.0, 0.0));
301 2 : assert_eq!((d.hours, d.minutes, d.seconds), (12.0, 30.0, 5.5));
302 2 : assert!(!d.whole, "a fractional component is not whole");
303 2 : assert!(!d.empty);
304 : // 4.6.3 leaves the fraction separator open
305 2 : assert_eq!(
306 2 : super::parse_iso_duration("PT0,5S").map(|d| d.seconds),
307 : Some(0.5)
308 : );
309 : // a bare P carries nothing to weigh, and is the only accepted shape
310 : // that carries nothing
311 2 : let bare = super::parse_iso_duration("P").expect("a bare P scans");
312 2 : assert!(bare.empty && bare.whole);
313 48 : for bad in [
314 2 : "",
315 2 : "PT",
316 2 : "P1DT", // a T with no time component after it
317 2 : "1Y",
318 2 : "P1",
319 2 : "PT1H1", // digits with no designator
320 2 : "P1X",
321 2 : "p1d",
322 2 : "P-1D",
323 2 : "P+1D",
324 2 : "P 1D",
325 2 : "PT1H ",
326 2 : " PT1H",
327 2 : "1PD",
328 2 : "P1H",
329 2 : "PT1D", // a designator in the wrong half
330 2 : "P1D2M",
331 2 : "PT1S1S", // out of order, and repeated
332 2 : "P,D",
333 2 : "P.D",
334 2 : "P..D",
335 2 : "P1.2.3D", // not a number
336 2 : "P\u{661}D",
337 2 : "P1D\u{0}",
338 2 : ] {
339 48 : assert_eq!(super::parse_iso_duration(bad), None, "{bad:?}");
340 : }
341 2 : }
342 :
343 : /// `whole` is what an EntityMap lifetime asks: the component is a plain
344 : /// digit run an `i64` of seconds can still hold.
345 : #[test]
346 2 : fn a_magnitude_past_i64_is_not_whole() {
347 6 : for s in ["PT99999999999999999999S", "PT1.5S", "PT1,5S"] {
348 6 : let d = super::parse_iso_duration(s).expect("it scans");
349 6 : assert!(!d.whole, "{s:?}");
350 : }
351 6 : for s in ["PT9223372036854775807S", "P0D", "PT0S"] {
352 6 : assert!(
353 6 : super::parse_iso_duration(s).expect("it scans").whole,
354 : "{s:?}"
355 : );
356 : }
357 2 : }
358 :
359 : /// The two views of the same list stay one list: `is_meta` answers
360 : /// Table 5.2.4-1's question — the Entity's OWN members — and
361 : /// `ENTITY_META_KEYS` answers the document's, which is the same set plus
362 : /// the `@context` a stored or rendered document carries.
363 : #[test]
364 2 : fn the_document_list_and_the_entity_list_agree() {
365 16 : for k in super::ENTITY_META_KEYS {
366 16 : assert_eq!(
367 16 : super::is_meta(k),
368 16 : *k != "@context",
369 : "{k} is in the document list"
370 : );
371 : }
372 2 : assert_eq!(
373 2 : super::ENTITY_META_KEYS.len(),
374 : 8,
375 : "seven Entity members plus @context"
376 : );
377 8 : for k in [
378 2 : "location",
379 2 : "speed",
380 2 : "https://uri.etsi.org/ngsi-ld/default-context/x",
381 2 : "",
382 2 : ] {
383 8 : assert!(!super::is_meta(k), "{k:?} is an Attribute, not a member");
384 8 : assert!(!super::ENTITY_META_KEYS.contains(&k), "{k:?}");
385 : }
386 2 : }
387 : }
|