LCOV - code coverage report
Current view: top level - antares-model/src - lib.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.5 % 196 195
Test Date: 2026-09-21 10:31:06 Functions: 81.8 % 33 27

            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              : }
        

Generated by: LCOV version 2.0-1