LCOV - code coverage report
Current view: top level - antares-api/src - conformance.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.0 % 583 577
Test Date: 2026-09-21 10:31:06 Functions: 68.6 % 105 72

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Version negotiation (6.3.6/6.3.21 + 5.8.6 `ngsildConformance`): amend
       3              : //! response payloads to conform to an earlier NGSI-LD version per the
       4              : //! backwards-compatibility fallbacks of clause 4.3.6.8 (Tables 4.3.6.8-1/2/3).
       5              : //!
       6              : //! `Prefer: ngsi-ld=<major.minor>` ⇒ apply the fallbacks, answer with
       7              : //! `Preference-Applied: ngsi-ld=<conformant-version>`, and 203 Non-Authoritative
       8              : //! instead of 200 when the payload was actually altered (the response tables'
       9              : //! "altered Entity" rows). A subscription's `ngsildConformance` applies the
      10              : //! same amendment to every notification (5.8.6).
      11              : 
      12              : use axum::body::Body;
      13              : use axum::http::{header, Request, StatusCode};
      14              : use axum::middleware::Next;
      15              : use axum::response::Response;
      16              : use serde_json::Value;
      17              : 
      18              : /// Members of an entity that are NOT attributes.
      19              : /// `"major.minor"` (4.3.6.8; a patch part as in `1.9.1` is tolerated).
      20          152 : pub(crate) fn parse_version(s: &str) -> Option<(u32, u32)> {
      21          152 :     let mut it = s.trim().split('.');
      22          152 :     let major = it.next()?.parse().ok()?;
      23          104 :     let minor = it.next()?.parse().ok()?;
      24           84 :     Some((major, minor))
      25          152 : }
      26              : 
      27              : /// The version this broker natively conforms to.
      28              : const NATIVE: (u32, u32) = (1, 9);
      29              : 
      30              : /// Amend one compacted entity document in place; true when anything changed.
      31           66 : pub(crate) fn amend_entity(doc: &mut Value, ver: (u32, u32)) -> bool {
      32           66 :     if ver >= NATIVE {
      33            4 :         return false;
      34           62 :     }
      35           62 :     let Some(obj) = doc.as_object_mut() else {
      36            0 :         return false;
      37              :     };
      38           62 :     let mut changed = false;
      39              :     // Table 4.3.6.8-1 entity-level members.
      40           62 :     if ver < (1, 9) && obj.remove("expiresAt").is_some() {
      41           16 :         changed = true;
      42           46 :     }
      43           62 :     if ver < (1, 4) && obj.remove("scope").is_some() {
      44           12 :         changed = true;
      45           50 :     }
      46           62 :     if ver < (1, 3) {
      47              :         // Note 1: 1.0 knows a single entity type — keep the first.
      48           24 :         if let Some(Value::Array(types)) = obj.get("type") {
      49            4 :             if let Some(first) = types.first().cloned() {
      50            4 :                 obj.insert("type".into(), first);
      51            4 :                 changed = true;
      52            4 :             }
      53           20 :         }
      54           38 :     }
      55           62 :     let attr_names: Vec<String> = obj
      56           62 :         .keys()
      57          244 :         .filter(|k| !crate::repr::ENTITY_META.contains(&k.as_str()))
      58           62 :         .cloned()
      59           62 :         .collect();
      60          116 :     for name in attr_names {
      61          116 :         if let Some(v) = obj.get_mut(&name) {
      62          116 :             changed |= amend_attr(v, ver);
      63          116 :         }
      64              :     }
      65           62 :     changed
      66           66 : }
      67              : 
      68              : /// One attribute node (object or multi-instance array), recursively.
      69          152 : fn amend_attr(v: &mut Value, ver: (u32, u32)) -> bool {
      70          152 :     let mut changed = false;
      71          152 :     if let Value::Array(instances) = v {
      72              :         // Notes 2/3: 1.0 has no datasetId multi-instances — keep the default
      73              :         // instance (the one without datasetId), else the first. Selection runs
      74              :         // BEFORE the <1.3 datasetId removal erases the tiebreaker.
      75              :         // A temporal instance array (clause 5.2.5) is a different shape —
      76              :         // every instance carries instanceId — and Notes 2/3 do not cover it,
      77              :         // so its history survives intact instead of collapsing to one point.
      78           28 :         let temporal = instances.iter().any(|i| i.get("instanceId").is_some());
      79           16 :         if ver < (1, 3) && !temporal {
      80            4 :             let pick = instances
      81            4 :                 .iter()
      82            8 :                 .position(|i| i.get("datasetId").is_none())
      83            4 :                 .unwrap_or(0);
      84            4 :             if let Some(mut one) = instances.get(pick).cloned() {
      85            4 :                 amend_attr(&mut one, ver);
      86            4 :                 *v = one;
      87            4 :                 return true;
      88            0 :             }
      89           12 :         }
      90           24 :         for i in instances.iter_mut() {
      91           24 :             changed |= amend_attr(i, ver);
      92           24 :         }
      93           12 :         return changed;
      94          136 :     }
      95          136 :     let Some(obj) = v.as_object_mut() else {
      96            0 :         return false;
      97              :     };
      98          136 :     let ty = obj.get("type").and_then(Value::as_str).unwrap_or("");
      99            8 :     match ty {
     100              :         // Table 4.3.6.8-1 attribute-type fallbacks.
     101          136 :         "LanguageProperty" if ver < (1, 4) => {
     102           12 :             obj.insert("type".into(), Value::String("Property".into()));
     103           12 :             if let Some(lm) = obj.remove("languageMap") {
     104           12 :                 obj.insert("value".into(), lm);
     105           12 :             }
     106           12 :             changed = true;
     107              :         }
     108          116 :         "JsonProperty" if ver < (1, 8) => {
     109           20 :             obj.insert("type".into(), Value::String("Property".into()));
     110           20 :             if let Some(j) = obj.remove("json") {
     111           20 :                 obj.insert("value".into(), j);
     112           20 :             }
     113           20 :             changed = true;
     114              :         }
     115           96 :         "VocabProperty" if ver < (1, 8) => {
     116            8 :             obj.insert("type".into(), Value::String("Property".into()));
     117            8 :             if let Some(vv) = obj.remove("vocab") {
     118            8 :                 obj.insert("value".into(), vv);
     119            8 :             }
     120            8 :             changed = true;
     121              :         }
     122           88 :         "ListProperty" if ver < (1, 8) => {
     123            8 :             obj.insert("type".into(), Value::String("Property".into()));
     124            8 :             if let Some(vl) = obj.remove("valueList") {
     125            8 :                 obj.insert("value".into(), vl);
     126            8 :             }
     127            8 :             changed = true;
     128              :         }
     129           80 :         "ListRelationship" if ver < (1, 8) => {
     130            8 :             obj.insert("type".into(), Value::String("Relationship".into()));
     131            8 :             if let Some(ol) = obj.remove("objectList") {
     132            8 :                 obj.insert("object".into(), ol);
     133            8 :             }
     134            8 :             changed = true;
     135              :         }
     136           80 :         _ => {}
     137              :     }
     138              :     // Tables 4.3.6.8-2/3 sub-member removals (shared version boundaries).
     139          136 :     if ver < (1, 3) {
     140          132 :         for k in ["datasetId", "observedAt", "unitCode"] {
     141          132 :             if obj.remove(k).is_some() {
     142           20 :                 changed = true;
     143          112 :             }
     144              :         }
     145           92 :     }
     146          136 :     if ver < (1, 8) && obj.remove("objectType").is_some() {
     147           12 :         changed = true;
     148          124 :     }
     149          136 :     if ver < (1, 9) {
     150          272 :         for k in ["valueType", "expiresAt"] {
     151          272 :             if obj.remove(k).is_some() {
     152           16 :                 changed = true;
     153          256 :             }
     154              :         }
     155            0 :     }
     156              :     // Sub-attributes (properties-of-properties) get the same treatment.
     157              :     // Everything the Attribute itself is made of is excluded, and that
     158              :     // includes the 5.2.14 `previous*` members a showChanges notification
     159              :     // carries: each holds the VALUE the Attribute held, "any JSON value as
     160              :     // defined by IETF RFC 8259" (Table 4.3.6.8-2) or a String[] object
     161              :     // (Table 4.3.6.8-3). Walking one as an Attribute node applies Notes 2/3
     162              :     // to a plain JSON array and hands the subscriber a single element where
     163              :     // its own previous value had several.
     164          136 :     let subs: Vec<String> = obj
     165          136 :         .keys()
     166          344 :         .filter(|k| {
     167          336 :             !matches!(
     168          344 :                 k.as_str(),
     169          344 :                 "type"
     170          208 :                     | "value"
     171          108 :                     | "object"
     172           80 :                     | "languageMap"
     173           72 :                     | "json"
     174           72 :                     | "vocab"
     175           72 :                     | "valueList"
     176           72 :                     | "objectList"
     177           72 :                     | "previousValue"
     178           60 :                     | "previousObject"
     179           56 :                     | "previousLanguageMap"
     180           52 :                     | "previousJson"
     181           52 :                     | "previousVocab"
     182           52 :                     | "previousValueList"
     183           52 :                     | "previousObjectList"
     184           52 :                     | "datasetId"
     185           44 :                     | "observedAt"
     186           32 :                     | "unitCode"
     187           20 :                     | "objectType"
     188           16 :                     | "valueType"
     189           16 :                     | "expiresAt"
     190           16 :                     | "createdAt"
     191           16 :                     | "modifiedAt"
     192           16 :                     | "instanceId"
     193              :             )
     194          344 :         })
     195          136 :         .cloned()
     196          136 :         .collect();
     197          136 :     for name in subs {
     198            8 :         if let Some(sub) = obj.get_mut(&name) {
     199            8 :             if sub.is_object() || sub.is_array() {
     200            8 :                 changed |= amend_attr(sub, ver);
     201            8 :             }
     202            0 :         }
     203              :     }
     204          136 :     changed
     205          152 : }
     206              : 
     207              : /// Amend whatever entity-bearing payload shape a response carries.
     208              : /// The 4.3.6.8 fallback tables describe Entity data. The other NGSI-LD
     209              : /// resources are served with the same id + type shape but have no version
     210              : /// fallbacks of their own, so they pass through untouched and keep their 200
     211              : /// rather than the 203 that marks an altered Entity. They are recognised by
     212              : /// their reserved clause 5.2 data-type name.
     213           26 : fn is_reserved_resource(o: &serde_json::Map<String, Value>) -> bool {
     214              :     const RESERVED: [&str; 6] = [
     215              :         "Subscription",
     216              :         "ContextSourceRegistration",
     217              :         "CSourceRegistration",
     218              :         "Notification",
     219              :         "EntityMap",
     220              :         "Snapshot",
     221              :     ];
     222           26 :     o.get("type")
     223           26 :         .and_then(Value::as_str)
     224           26 :         .is_some_and(|t| RESERVED.contains(&t))
     225           26 : }
     226              : 
     227           50 : pub(crate) fn amend_payload(doc: &mut Value, ver: (u32, u32)) -> bool {
     228           50 :     match doc {
     229            8 :         Value::Array(items) => {
     230            8 :             let mut changed = false;
     231            8 :             for i in items {
     232            4 :                 changed |= amend_payload(i, ver);
     233            4 :             }
     234            8 :             changed
     235              :         }
     236           38 :         Value::Object(o) => {
     237           38 :             if o.get("type").and_then(Value::as_str) == Some("Notification") {
     238            8 :                 match o.get_mut("data") {
     239            4 :                     Some(d) => amend_payload(d, ver),
     240            4 :                     None => false,
     241              :                 }
     242           30 :             } else if o.contains_key("id") && o.contains_key("type") && !is_reserved_resource(o) {
     243           22 :                 amend_entity(doc, ver)
     244              :             } else {
     245            8 :                 false
     246              :             }
     247              :         }
     248            4 :         _ => false,
     249              :     }
     250           50 : }
     251              : 
     252              : /// `Prefer: ngsi-ld=<version>` from a raw Prefer header value (RFC 7240 —
     253              : /// preferences are comma-separated `token=value` pairs).
     254          112 : pub(crate) fn preferred_version(prefer: &str) -> Option<(u32, u32)> {
     255          120 :     prefer.split(',').find_map(|p| {
     256          120 :         let (k, v) = p.split_once('=')?;
     257              :         // RFC 9110 clause 5.6.2: a field name is case-insensitive, and RFC
     258              :         // 7240 lets a preference carry its own parameters after ';'.
     259          108 :         if !k.trim().eq_ignore_ascii_case("ngsi-ld") {
     260           28 :             return None;
     261           80 :         }
     262           80 :         parse_version(v.split(';').next()?.trim().trim_matches('"'))
     263          120 :     })
     264          112 : }
     265              : 
     266              : /// Router middleware (6.3.6): honour `Prefer: ngsi-ld=` on JSON responses.
     267        26106 : pub async fn prefer_version_layer(req: Request<Body>, next: Next) -> Response {
     268              :     // RFC 9110 clause 5.3: repeated field lines carry the same meaning as one
     269              :     // comma-separated list, so the preference is looked for on every line.
     270        26106 :     let requested = req
     271        26106 :         .headers()
     272        26106 :         .get_all("prefer")
     273        26106 :         .iter()
     274        26106 :         .filter_map(|h| h.to_str().ok())
     275        26106 :         .find_map(preferred_version);
     276        26106 :     let Some(ver) = requested else {
     277        26062 :         return next.run(req).await;
     278              :     };
     279           44 :     let resp = next.run(req).await;
     280           44 :     let is_json = resp
     281           44 :         .headers()
     282           44 :         .get(header::CONTENT_TYPE)
     283           44 :         .and_then(|h| h.to_str().ok())
     284           44 :         .is_some_and(|ct| {
     285           44 :             ct.starts_with("application/json") || ct.starts_with("application/ld+json")
     286           44 :         });
     287           44 :     if resp.status() != StatusCode::OK || !is_json {
     288            4 :         return resp;
     289           40 :     }
     290              :     // 6.3.6 owes `Preference-Applied` to every `Prefer: ngsi-ld=` request,
     291              :     // but at or above the version this broker serves natively there is
     292              :     // nothing to amend — every rule in Table 4.3.6.8-1 is guarded by
     293              :     // `ver < …` and `amend_entity` returns immediately on `ver >= NATIVE`.
     294              :     // So the header is the whole answer, and the handler's body reaches the
     295              :     // client as the handler wrote it: never buffered whole, never parsed,
     296              :     // never re-serialized, and (unlike the amend path below) with its own
     297              :     // Content-Length intact.
     298           40 :     if ver >= NATIVE {
     299           16 :         let mut resp = resp;
     300           16 :         if let Ok(v) = format!("ngsi-ld={}.{}", NATIVE.0, NATIVE.1).parse() {
     301           16 :             resp.headers_mut().insert("Preference-Applied", v);
     302           16 :         }
     303           16 :         return resp;
     304           24 :     }
     305           24 :     let (mut parts, body) = resp.into_parts();
     306              :     // Honouring the preference is optional (RFC 7240 section 2), so the
     307              :     // buffer is bounded by the same cap the request wall advertises: a
     308              :     // response bigger than MAX_BODY_BYTES passes through byte-identical,
     309              :     // unamended and without Preference-Applied.
     310              :     use futures_util::StreamExt;
     311           24 :     let mut stream = body.into_data_stream();
     312           24 :     let mut buf: Vec<u8> = Vec::new();
     313              :     loop {
     314           42 :         match stream.next().await {
     315           18 :             None => break,
     316              :             // The upstream body failed mid-stream, so the prefix already read
     317              :             // is not the response and must not be served under the handler's
     318              :             // 200. The transport error text stays server-side (5.5.6).
     319              :             Some(Err(_)) => {
     320            4 :                 parts.status = StatusCode::INTERNAL_SERVER_ERROR;
     321            4 :                 parts.headers.remove(header::CONTENT_LENGTH);
     322            4 :                 return Response::from_parts(parts, Body::empty());
     323              :             }
     324           20 :             Some(Ok(chunk)) => {
     325           20 :                 if buf.len() + chunk.len() > *crate::bounds::MAX_BODY_BYTES {
     326              :                     // stitch the already-read prefix back in front of the
     327              :                     // untouched remainder of the stream
     328            2 :                     let read = futures_util::stream::iter([
     329            2 :                         Ok::<_, axum::Error>(axum::body::Bytes::from(buf)),
     330            2 :                         Ok(chunk),
     331            2 :                     ]);
     332            2 :                     return Response::from_parts(parts, Body::from_stream(read.chain(stream)));
     333           18 :                 }
     334           18 :                 buf.extend_from_slice(&chunk);
     335              :             }
     336              :         }
     337              :     }
     338           18 :     let bytes = axum::body::Bytes::from(buf);
     339           18 :     let conformant = ver;
     340           18 :     let mut altered = false;
     341           18 :     let bytes = match serde_json::from_slice::<Value>(&bytes) {
     342           18 :         Ok(mut doc) => {
     343           18 :             altered = amend_payload(&mut doc, ver);
     344              :             // Re-serialize in the egress key order every other response path
     345              :             // uses (id and type first), not serde_json's own map order.
     346           18 :             crate::negotiate::ordered_vec(&doc).into()
     347              :         }
     348            0 :         Err(_) => bytes,
     349              :     };
     350              :     // Two integers and a dot are always a legal header value; if that ever
     351              :     // stopped being true, omitting the header beats taking the response down.
     352           18 :     if let Ok(v) = format!("ngsi-ld={}.{}", conformant.0, conformant.1).parse() {
     353           18 :         parts.headers.insert("Preference-Applied", v);
     354           18 :     }
     355           18 :     if altered {
     356            8 :         // The response tables' "altered Entity" rows: 203 Non-Authoritative.
     357            8 :         parts.status = StatusCode::NON_AUTHORITATIVE_INFORMATION;
     358           10 :     }
     359           18 :     parts.headers.remove(header::CONTENT_LENGTH);
     360           18 :     let mut resp = Response::from_parts(parts, Body::from(bytes));
     361           18 :     resp.headers_mut().remove(header::TRANSFER_ENCODING);
     362           18 :     resp
     363        26106 : }
     364              : 
     365              : #[cfg(test)]
     366              : mod tests {
     367              :     use super::*;
     368              :     use serde_json::json;
     369              : 
     370              :     #[test]
     371            4 :     fn version_parsing() {
     372            4 :         assert_eq!(parse_version("1.5"), Some((1, 5)));
     373            4 :         assert_eq!(parse_version("1.9.1"), Some((1, 9)));
     374            4 :         assert_eq!(parse_version("junk"), None);
     375            4 :         assert_eq!(preferred_version("ngsi-ld=1.6"), Some((1, 6)));
     376            4 :         assert_eq!(preferred_version("body=json, ngsi-ld=1.4"), Some((1, 4)));
     377            4 :         assert_eq!(preferred_version("body=json"), None);
     378            4 :     }
     379              : 
     380              :     /// 5.2.14: with `showChanges` an Attribute carries the value it HELD, in
     381              :     /// a `previous*` member. That member is an NGSI-LD Value (Table
     382              :     /// 4.3.6.8-2: "Any JSON value as defined by IETF RFC 8259") or, for a
     383              :     /// Relationship, a `String or String[]` object — not a sub-Attribute. The
     384              :     /// 4.3.6.8 tables say nothing about it, so the amender must carry it
     385              :     /// through untouched. Walking into it as if it were an Attribute node
     386              :     /// applies Notes 2/3 to a plain JSON array and hands the subscriber one
     387              :     /// element where its own previous value had several.
     388              :     #[test]
     389            4 :     fn show_changes_previous_members_are_values_not_sub_attributes() {
     390            4 :         let mk = || {
     391            4 :             json!({"id": "urn:a", "type": "T",
     392            4 :                 "speed": {"type": "Property", "value": [1, 2, 3],
     393            4 :                           "previousValue": [9, 8, 7]},
     394            4 :                 "where": {"type": "GeoProperty",
     395            4 :                           "value": {"type": "LineString",
     396            4 :                                     "coordinates": [[1.0, 2.0], [3.0, 4.0]]},
     397            4 :                           "previousValue": {"type": "LineString",
     398            4 :                                             "coordinates": [[5.0, 6.0], [7.0, 8.0]]}},
     399            4 :                 "near": {"type": "Relationship", "object": ["urn:b", "urn:c"],
     400            4 :                          "previousObject": ["urn:d", "urn:e"]},
     401            4 :                 "label": {"type": "LanguageProperty", "languageMap": {"en": "now"},
     402            4 :                           "previousLanguageMap": {"en": "then"}}})
     403            4 :         };
     404              :         // 1.2 is below every Notes 2/3 boundary — the harshest amendment.
     405            4 :         let mut d = mk();
     406            4 :         amend_entity(&mut d, (1, 2));
     407            4 :         assert_eq!(
     408            4 :             d["speed"]["previousValue"],
     409            4 :             json!([9, 8, 7]),
     410              :             "a previous Value array is a value, not a multi-instance Attribute"
     411              :         );
     412            4 :         assert_eq!(
     413            4 :             d["where"]["previousValue"],
     414            4 :             json!({"type": "LineString", "coordinates": [[5.0, 6.0], [7.0, 8.0]]}),
     415              :             "a previous geometry keeps every position"
     416              :         );
     417            4 :         assert_eq!(
     418            4 :             d["near"]["previousObject"],
     419            4 :             json!(["urn:d", "urn:e"]),
     420              :             "a Relationship object is String or String[] (Table 4.3.6.8-3)"
     421              :         );
     422            4 :         assert_eq!(
     423            4 :             d["label"]["previousLanguageMap"],
     424            4 :             json!({"en": "then"}),
     425              :             "a previous languageMap is not walked as an Attribute either"
     426              :         );
     427              :         // and the members the tables DO govern still fall back
     428            4 :         let mut d = json!({"id": "urn:a", "type": "T",
     429            4 :             "speed": {"type": "Property", "value": 1, "observedAt": "2026-01-01T00:00:00Z",
     430            4 :                       "previousValue": [9, 8, 7]}});
     431            4 :         assert!(amend_entity(&mut d, (1, 2)));
     432            4 :         assert!(
     433            4 :             d["speed"].get("observedAt").is_none(),
     434              :             "observedAt is a 1.3 member and still goes"
     435              :         );
     436            4 :         assert_eq!(d["speed"]["previousValue"], json!([9, 8, 7]));
     437            4 :     }
     438              : 
     439              :     #[test]
     440            4 :     fn new_attribute_types_fall_back_per_version() {
     441           12 :         let mk = || {
     442           12 :             json!({"id": "urn:a", "type": "T",
     443           12 :                 "lp": {"type": "LanguageProperty", "languageMap": {"en": "hi"}},
     444           12 :                 "jp": {"type": "JsonProperty", "json": {"k": 1}},
     445           12 :                 "vp": {"type": "VocabProperty", "vocab": "V"},
     446           12 :                 "list": {"type": "ListProperty", "valueList": [1, 2]},
     447           12 :                 "lr": {"type": "ListRelationship", "objectList": ["urn:b"]}})
     448           12 :         };
     449              :         // 1.8 understands Json/Vocab/List*, not... everything stays but nothing
     450              :         // else: only versions below the introduction boundary reformat.
     451            4 :         let mut d = mk();
     452            4 :         assert!(!amend_entity(&mut d, (1, 9)), "native version: unchanged");
     453            4 :         let mut d = mk();
     454            4 :         assert!(amend_entity(&mut d, (1, 4)));
     455            4 :         assert_eq!(d["jp"], json!({"type": "Property", "value": {"k": 1}}));
     456            4 :         assert_eq!(d["vp"], json!({"type": "Property", "value": "V"}));
     457            4 :         assert_eq!(d["list"], json!({"type": "Property", "value": [1, 2]}));
     458            4 :         assert_eq!(
     459            4 :             d["lr"],
     460            4 :             json!({"type": "Relationship", "object": ["urn:b"]})
     461              :         );
     462            4 :         assert_eq!(
     463            4 :             d["lp"],
     464            4 :             json!({"type": "LanguageProperty", "languageMap": {"en": "hi"}}),
     465              :             "LanguageProperty exists since 1.4"
     466              :         );
     467            4 :         let mut d = mk();
     468            4 :         assert!(amend_entity(&mut d, (1, 3)));
     469            4 :         assert_eq!(
     470            4 :             d["lp"],
     471            4 :             json!({"type": "Property", "value": {"en": "hi"}}),
     472              :             "1.3 predates LanguageProperty"
     473              :         );
     474            4 :     }
     475              : 
     476              :     #[test]
     477            4 :     fn one_dot_zero_single_type_and_default_instance() {
     478            4 :         let mut d = json!({"id": "urn:a", "type": ["A", "B"],
     479            4 :         "speed": [
     480            4 :             {"type": "Property", "value": 1, "datasetId": "urn:ds:1"},
     481            4 :             {"type": "Property", "value": 2}
     482              :         ]});
     483            4 :         assert!(amend_entity(&mut d, (1, 0)));
     484            4 :         assert_eq!(d["type"], "A", "note 1: single type, first wins");
     485            4 :         assert_eq!(
     486            4 :             d["speed"],
     487            4 :             json!({"type": "Property", "value": 2}),
     488              :             "note 2: default instance preferred, datasetId gone (<1.3)"
     489              :         );
     490            4 :     }
     491              : 
     492              :     #[test]
     493            4 :     fn sub_member_removals_by_boundary() {
     494            4 :         let mut d = json!({"id": "urn:a", "type": "T", "expiresAt": "2030-01-01T00:00:00Z",
     495            4 :             "scope": "/a",
     496            4 :             "r": {"type": "Relationship", "object": "urn:b", "objectType": "B",
     497            4 :                    "observedAt": "2020-01-01T00:00:00Z",
     498            4 :                    "nested": {"type": "Property", "value": 1, "unitCode": "C"}}});
     499            4 :         let mut d13 = d.clone();
     500            4 :         assert!(amend_entity(&mut d13, (1, 3)));
     501            4 :         assert!(d13.get("scope").is_none(), "scope is 1.4");
     502            4 :         assert!(d13.get("expiresAt").is_none(), "entity expiresAt is 1.9");
     503            4 :         assert!(d13["r"].get("objectType").is_none(), "objectType is 1.8");
     504            4 :         assert!(
     505            4 :             d13["r"].get("observedAt").is_some(),
     506              :             "observedAt fine at 1.3"
     507              :         );
     508            4 :         assert!(
     509            4 :             d13["r"]["nested"].get("unitCode").is_some(),
     510              :             "unitCode fine at 1.3"
     511              :         );
     512            4 :         assert!(amend_entity(&mut d, (1, 0)));
     513            4 :         assert!(d["r"].get("observedAt").is_none());
     514            4 :         assert!(d["r"]["nested"].get("unitCode").is_none());
     515            4 :     }
     516              : 
     517              :     #[test]
     518            4 :     fn notification_data_is_amended() {
     519            4 :         let mut n = json!({"id": "urn:n:1", "type": "Notification",
     520            4 :             "data": [{"id": "urn:a", "type": "T",
     521            4 :                       "jp": {"type": "JsonProperty", "json": 1}}]});
     522            4 :         assert!(amend_payload(&mut n, (1, 6)));
     523            4 :         assert_eq!(n["data"][0]["jp"], json!({"type": "Property", "value": 1}));
     524            4 :     }
     525              : 
     526              :     /// A version string that is not `major.minor` yields no version at all —
     527              :     /// the preference is then ignored rather than guessed at.
     528              :     #[test]
     529            4 :     fn version_parsing_rejects_malformed_input() {
     530           40 :         for bad in [
     531            4 :             "", "1", "1.", ".", ".5", "1.x", "-1.2", "1.-2", "v1.5", "1 . 5",
     532            4 :         ] {
     533           40 :             assert_eq!(parse_version(bad), None, "{bad:?} is not major.minor");
     534              :         }
     535              :         // out of u32 range on either part
     536            4 :         assert_eq!(parse_version("4294967296.0"), None);
     537            4 :         assert_eq!(parse_version("1.4294967296"), None);
     538            4 :         assert_eq!(parse_version(" 1.5 "), Some((1, 5)), "outer space trimmed");
     539            4 :     }
     540              : 
     541              :     /// RFC 7240: preference tokens are case-insensitive and may carry
     542              :     /// `;`-separated parameters; a preference that is not `ngsi-ld` (or whose
     543              :     /// value is not a version) leaves version negotiation off.
     544              :     #[test]
     545            4 :     fn prefer_header_forms() {
     546            4 :         assert_eq!(preferred_version("NGSI-LD=1.5"), Some((1, 5)));
     547            4 :         assert_eq!(preferred_version("Ngsi-Ld=\"1.5\""), Some((1, 5)));
     548            4 :         assert_eq!(preferred_version("ngsi-ld=1.5; foo=bar"), Some((1, 5)));
     549            4 :         assert_eq!(
     550            4 :             preferred_version("body=json, ngsi-ld=1.5;q=0.1"),
     551              :             Some((1, 5))
     552              :         );
     553           24 :         for none in [
     554            4 :             "",
     555            4 :             "ngsi-ld",
     556            4 :             "ngsi-ld=",
     557            4 :             "ngsi-ld=junk",
     558            4 :             "body=json",
     559            4 :             "respond-async",
     560            4 :         ] {
     561           24 :             assert_eq!(preferred_version(none), None, "{none:?}");
     562              :         }
     563            4 :     }
     564              : 
     565              :     /// Table 4.3.6.8-1/2/3 "Version Introduced" column: a member is only
     566              :     /// dropped BELOW the version that introduced it — at that version it must
     567              :     /// still be present.
     568              :     #[test]
     569            4 :     fn members_survive_at_their_introduction_version() {
     570            8 :         let mk = || {
     571            8 :             json!({"id": "urn:a", "type": ["A", "B"], "scope": "/s",
     572            8 :                 "expiresAt": "2030-01-01T00:00:00Z",
     573            8 :                 "p": [{"type": "Property", "value": 1, "datasetId": "urn:ds:1",
     574            8 :                        "observedAt": "2020-01-01T00:00:00Z", "unitCode": "C",
     575            8 :                        "valueType": "http://x/T", "expiresAt": "2030-01-01T00:00:00Z"},
     576            8 :                       {"type": "Property", "value": 2}],
     577            8 :                 "lp": {"type": "LanguageProperty", "languageMap": {"en": "hi"}},
     578            8 :                 "r": {"type": "Relationship", "object": "urn:b", "objectType": "B"}})
     579            8 :         };
     580            4 :         let mut d = mk();
     581            4 :         assert!(amend_entity(&mut d, (1, 8)));
     582            4 :         assert!(d["p"][0].get("datasetId").is_some(), "datasetId is 1.3");
     583            4 :         assert!(d["p"][0].get("objectType").is_none(), "attr has none");
     584            4 :         assert!(d["r"].get("objectType").is_some(), "objectType is 1.8");
     585            4 :         assert!(d["p"][0].get("valueType").is_none(), "valueType is 1.9");
     586            4 :         assert!(
     587            4 :             d["p"][0].get("expiresAt").is_none(),
     588              :             "attr expiresAt is 1.9"
     589              :         );
     590            4 :         assert!(d.get("expiresAt").is_none(), "entity expiresAt is 1.9");
     591            4 :         assert!(d.get("scope").is_some(), "scope is 1.4");
     592            4 :         assert_eq!(d["type"], json!(["A", "B"]), "multi-type is 1.3");
     593            4 :         assert!(d["p"].is_array(), "multi-instance is 1.3");
     594            4 :         assert_eq!(
     595            4 :             d["lp"]["type"], "LanguageProperty",
     596              :             "LanguageProperty is 1.4"
     597              :         );
     598              : 
     599            4 :         let mut d = mk();
     600            4 :         assert!(amend_entity(&mut d, (1, 3)));
     601            4 :         assert!(d["p"][0].get("observedAt").is_some(), "observedAt is 1.3");
     602            4 :         assert!(d["p"][0].get("unitCode").is_some(), "unitCode is 1.3");
     603            4 :         assert!(d.get("scope").is_none(), "scope only from 1.4");
     604            4 :         assert!(
     605            4 :             d["r"].get("objectType").is_none(),
     606              :             "objectType only from 1.8"
     607              :         );
     608            4 :         assert_eq!(
     609            4 :             d["lp"]["type"], "Property",
     610              :             "LanguageProperty only from 1.4"
     611              :         );
     612            4 :         assert!(d["lp"].get("languageMap").is_none(), "reformatted away");
     613            4 :     }
     614              : 
     615              :     /// Notes 2/3 collapse the datasetId-separated instances of an Entity
     616              :     /// attribute. The temporal representation's instance array (clause 5.2.5,
     617              :     /// every instance carrying `instanceId`) is a different shape and is not
     618              :     /// covered by the table — it must survive intact.
     619              :     #[test]
     620            4 :     fn temporal_instance_arrays_are_not_collapsed() {
     621            4 :         let mut d = json!({"id": "urn:a", "type": "T",
     622            4 :         "speed": [
     623            4 :             {"type": "Property", "value": 1, "observedAt": "2020-01-01T00:00:00Z",
     624            4 :              "instanceId": "urn:ngsi-ld:Instance:1"},
     625            4 :             {"type": "Property", "value": 2, "observedAt": "2020-01-02T00:00:00Z",
     626            4 :              "instanceId": "urn:ngsi-ld:Instance:2"}
     627              :         ]});
     628            4 :         amend_entity(&mut d, (1, 0));
     629            4 :         assert_eq!(
     630            4 :             d["speed"].as_array().map(Vec::len),
     631              :             Some(2),
     632              :             "temporal instances must not collapse to one"
     633              :         );
     634            4 :         assert!(d["speed"][0].get("observedAt").is_none(), "observedAt <1.3");
     635            4 :     }
     636              : 
     637              :     /// Payload shapes that carry no Entity data are left alone: a document
     638              :     /// without both `id` and `type`, a scalar, and a Notification with no
     639              :     /// `data` member.
     640              :     #[test]
     641            4 :     fn non_entity_payloads_are_untouched() {
     642            4 :         let mut v = json!({"title": "BadRequestData", "status": 400});
     643            4 :         assert!(!amend_payload(&mut v, (1, 0)));
     644            4 :         let mut v = json!("just a string");
     645            4 :         assert!(!amend_payload(&mut v, (1, 0)));
     646            4 :         let mut v = json!({"id": "urn:n", "type": "Notification"});
     647            4 :         assert!(!amend_payload(&mut v, (1, 0)));
     648            4 :         let mut v = json!([]);
     649            4 :         assert!(!amend_payload(&mut v, (1, 0)));
     650              :         // an already-conformant entity reports no change
     651            4 :         let mut v = json!({"id": "urn:a", "type": "T", "p": {"type": "Property", "value": 1}});
     652            4 :         assert!(!amend_payload(&mut v, (1, 0)));
     653            4 :     }
     654              : }
     655              : 
     656              : /// Middleware behaviour of the `Prefer: ngsi-ld=` layer (6.3.6): what it
     657              : /// amends, what it must leave byte-identical, and the status it answers with.
     658              : #[cfg(test)]
     659              : mod prefer_layer {
     660              :     use super::*;
     661              :     use axum::http::HeaderValue;
     662              :     use http_body_util::BodyExt;
     663              :     use tower::ServiceExt;
     664              : 
     665           36 :     fn app(path: &'static str, payload: &'static str) -> axum::Router {
     666           36 :         axum::Router::new()
     667           36 :             .route(
     668           36 :                 path,
     669           36 :                 axum::routing::get(move || async move {
     670           36 :                     ([(header::CONTENT_TYPE, "application/json")], payload)
     671           72 :                 }),
     672              :             )
     673           36 :             .layer(axum::middleware::from_fn(prefer_version_layer))
     674           36 :     }
     675              : 
     676           40 :     async fn get(
     677           40 :         app: axum::Router,
     678           40 :         uri: &str,
     679           40 :         prefer: Option<&str>,
     680           40 :     ) -> (StatusCode, String, String) {
     681           40 :         let mut b = Request::builder().uri(uri);
     682           40 :         if let Some(p) = prefer {
     683           36 :             b = b.header("Prefer", p);
     684           36 :         }
     685           40 :         let resp = app
     686           40 :             .oneshot(b.body(Body::empty()).expect("request"))
     687           40 :             .await
     688           40 :             .expect("response");
     689           40 :         let status = resp.status();
     690           40 :         let applied = resp
     691           40 :             .headers()
     692           40 :             .get("Preference-Applied")
     693           40 :             .and_then(|v| v.to_str().ok())
     694           40 :             .unwrap_or("")
     695           40 :             .to_owned();
     696           40 :         let bytes = resp.into_body().collect().await.expect("body").to_bytes();
     697           40 :         (
     698           40 :             status,
     699           40 :             applied,
     700           40 :             String::from_utf8_lossy(&bytes).into_owned(),
     701           40 :         )
     702           40 :     }
     703              : 
     704              :     /// The 4.3.6.8 fallbacks describe Entity data. A Subscription (5.2.12)
     705              :     /// has no version fallbacks: its members — including `expiresAt`, which
     706              :     /// is a Subscription member since 1.0 — and its string arrays must come
     707              :     /// back exactly as served, with 200 rather than the 203 that marks an
     708              :     /// altered Entity.
     709              :     #[tokio::test(flavor = "multi_thread")]
     710            4 :     async fn subscription_payload_is_never_amended() {
     711            4 :         let sub = r#"{"id":"urn:ngsi-ld:Subscription:1","type":"Subscription","expiresAt":"2030-01-01T00:00:00Z","notification":{"attributes":["a","b"],"format":"normalized"}}"#;
     712            4 :         let (status, applied, body) = get(
     713            4 :             app("/ngsi-ld/v1/subscriptions/{id}", sub),
     714            4 :             "/ngsi-ld/v1/subscriptions/urn:ngsi-ld:Subscription:1",
     715            4 :             Some("ngsi-ld=1.0"),
     716              :         )
     717            4 :         .await;
     718            4 :         assert_eq!(status, StatusCode::OK, "not an altered Entity: {body}");
     719            4 :         assert_eq!(applied, "ngsi-ld=1.0");
     720            4 :         assert_eq!(body, sub, "subscription served byte-identical");
     721            4 :     }
     722              : 
     723              :     /// An amended Entity keeps the egress key order (`id`/`type` first) and
     724              :     /// answers 203; an Entity that needed no amendment stays byte-identical
     725              :     /// and 200.
     726              :     #[tokio::test(flavor = "multi_thread")]
     727            4 :     async fn amended_entity_keeps_key_order_and_reports_203() {
     728            4 :         let ent = r#"{"id":"urn:a","type":"T","attr":{"type":"JsonProperty","json":{"k":1}}}"#;
     729            4 :         let (status, applied, body) = get(
     730            4 :             app("/ngsi-ld/v1/entities/{id}", ent),
     731            4 :             "/ngsi-ld/v1/entities/urn:a",
     732            4 :             Some("ngsi-ld=1.4"),
     733              :         )
     734            4 :         .await;
     735            4 :         assert_eq!(status, StatusCode::NON_AUTHORITATIVE_INFORMATION);
     736            4 :         assert_eq!(applied, "ngsi-ld=1.4");
     737            4 :         assert!(body.starts_with(r#"{"id":"urn:a","type":"T","#), "{body}");
     738            4 :         assert!(!body.contains("JsonProperty"), "reformatted away: {body}");
     739            4 :         assert!(!body.contains("\"json\""), "the 1.8 member is gone: {body}");
     740              : 
     741              :         // native/newer preference: nothing to amend, nothing to reorder
     742            4 :         let (status, applied, body) = get(
     743            4 :             app("/ngsi-ld/v1/entities/{id}", ent),
     744            4 :             "/ngsi-ld/v1/entities/urn:a",
     745            4 :             Some("ngsi-ld=2.0"),
     746              :         )
     747            4 :         .await;
     748            4 :         assert_eq!(status, StatusCode::OK);
     749            4 :         assert_eq!(applied, "ngsi-ld=1.9", "the version actually conformed to");
     750            4 :         assert_eq!(body, ent);
     751            4 :     }
     752              : 
     753              :     /// Without the preference the layer is inert, and it never touches a
     754              :     /// non-JSON body or a non-200 response.
     755              :     #[tokio::test(flavor = "multi_thread")]
     756            4 :     async fn layer_is_inert_without_a_usable_preference() {
     757            4 :         let ent = r#"{"id":"urn:a","type":"T","attr":{"type":"JsonProperty","json":1}}"#;
     758           12 :         for prefer in [None, Some("body=json"), Some("ngsi-ld=junk")] {
     759           12 :             let (status, applied, body) = get(
     760           12 :                 app("/ngsi-ld/v1/entities/{id}", ent),
     761           12 :                 "/ngsi-ld/v1/entities/urn:a",
     762           12 :                 prefer,
     763              :             )
     764           12 :             .await;
     765           12 :             assert_eq!(status, StatusCode::OK);
     766           12 :             assert!(applied.is_empty(), "no preference was applied: {prefer:?}");
     767           12 :             assert_eq!(body, ent);
     768              :         }
     769              : 
     770            4 :         let app = axum::Router::new()
     771            4 :             .route(
     772            4 :                 "/ngsi-ld/v1/entities/{id}",
     773            4 :                 axum::routing::get(|| async {
     774            4 :                     (
     775            4 :                         StatusCode::CREATED,
     776            4 :                         [(header::CONTENT_TYPE, "text/plain")],
     777            4 :                         "not json",
     778            4 :                     )
     779            8 :                 }),
     780              :             )
     781            4 :             .layer(axum::middleware::from_fn(prefer_version_layer));
     782            4 :         let (status, applied, body) =
     783            4 :             get(app, "/ngsi-ld/v1/entities/urn:a", Some("ngsi-ld=1.0")).await;
     784            4 :         assert_eq!(status, StatusCode::CREATED);
     785            4 :         assert!(applied.is_empty(), "non-JSON non-200 is passed through");
     786            4 :         assert_eq!(body, "not json");
     787            4 :     }
     788              : 
     789              :     /// A body that fails mid-stream must not be served as a complete 200.
     790              :     #[tokio::test(flavor = "multi_thread")]
     791            4 :     async fn broken_body_stream_is_not_a_success() {
     792            4 :         let app = axum::Router::new()
     793            4 :             .route(
     794            4 :                 "/ngsi-ld/v1/entities/{id}",
     795            4 :                 axum::routing::get(|| async {
     796            4 :                     let s = futures_util::stream::iter(vec![Err::<axum::body::Bytes, _>(
     797            4 :                         std::io::Error::other("upstream gone"),
     798            4 :                     )]);
     799            4 :                     Response::builder()
     800            4 :                         .header(header::CONTENT_TYPE, "application/json")
     801            4 :                         .body(Body::from_stream(s))
     802            4 :                         .expect("response")
     803            8 :                 }),
     804              :             )
     805            4 :             .layer(axum::middleware::from_fn(prefer_version_layer));
     806            4 :         let (status, _, body) = get(app, "/ngsi-ld/v1/entities/urn:a", Some("ngsi-ld=1.0")).await;
     807            4 :         assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
     808            4 :         assert!(
     809            4 :             !body.contains("upstream gone"),
     810            4 :             "the transport error must not reach the client: {body}"
     811            4 :         );
     812            4 :     }
     813              : 
     814              :     /// RFC 9110 5.3: repeated field lines are equivalent to one
     815              :     /// comma-separated list, so the version preference is honoured whichever
     816              :     /// line carries it.
     817              :     #[tokio::test(flavor = "multi_thread")]
     818            4 :     async fn version_preference_found_on_a_second_prefer_line() {
     819            4 :         let ent = r#"{"id":"urn:a","type":"T"}"#;
     820            4 :         let mut req = Request::builder().uri("/ngsi-ld/v1/entities/urn:a");
     821            4 :         if let Some(h) = req.headers_mut() {
     822            4 :             h.append("Prefer", HeaderValue::from_static("body=json"));
     823            4 :             h.append("Prefer", HeaderValue::from_static("ngsi-ld=1.5"));
     824            4 :         }
     825            4 :         let resp = app("/ngsi-ld/v1/entities/{id}", ent)
     826            4 :             .oneshot(req.body(Body::empty()).expect("request"))
     827            4 :             .await
     828            4 :             .expect("response");
     829            4 :         assert_eq!(
     830            4 :             resp.headers()
     831            4 :                 .get("Preference-Applied")
     832            4 :                 .and_then(|v| v.to_str().ok()),
     833            4 :             Some("ngsi-ld=1.5")
     834            4 :         );
     835            4 :     }
     836              : 
     837              :     /// 6.3.6: a `Prefer: ngsi-ld=` request is owed a `Preference-Applied`
     838              :     /// header whatever version it names, including the one the broker serves
     839              :     /// natively. At that version Table 4.3.6.8-1 has nothing to amend —
     840              :     /// `amend_entity` returns on `ver >= NATIVE` — so the header is all that
     841              :     /// is owed: the handler's bytes reach the client as they were written,
     842              :     /// never buffered, parsed and re-serialized on the way.
     843              :     #[tokio::test(flavor = "multi_thread")]
     844            4 :     async fn the_native_version_is_stamped_and_the_body_passes_through() {
     845              :         // key order the egress serializer would rewrite, so a body that comes
     846              :         // back reordered proves the layer round-tripped it through serde
     847            4 :         let payload = r#"{"type":"T","id":"urn:a"}"#;
     848            8 :         for asked in ["ngsi-ld=1.9", "ngsi-ld=2.0"] {
     849            8 :             let (status, applied, body) = get(app("/e", payload), "/e", Some(asked)).await;
     850            8 :             assert_eq!(status, StatusCode::OK, "{asked}");
     851            8 :             assert_eq!(applied, "ngsi-ld=1.9", "{asked}");
     852            8 :             assert_eq!(body, payload, "{asked}: the body is passed through");
     853            4 :         }
     854            4 :     }
     855              : }
        

Generated by: LCOV version 2.0-1