LCOV - code coverage report
Current view: top level - antares-jsonld/src - compact.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 100.0 % 471 471
Test Date: 2026-11-02 13:21:27 Functions: 98.0 % 50 49

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Compaction: internal expanded form → response document under the request
       3              : //! @context. Never mutates its input (enforced by the &input signature).
       4              : 
       5              : use crate::context::Context;
       6              : use serde_json::{Map, Value};
       7              : use std::borrow::Cow;
       8              : 
       9              : /// Entity-instance members whose values stay verbatim during compaction.
      10              : const VERBATIM: &[&str] = &[
      11              :     "type",
      12              :     "value",
      13              :     "object",
      14              :     "datasetId",
      15              :     "observedAt",
      16              :     "unitCode",
      17              :     "lang",
      18              :     "languageMap",
      19              :     "json",
      20              :     "valueList",
      21              :     "objectList",
      22              :     "createdAt",
      23              :     "modifiedAt",
      24              :     "deletedAt",
      25              :     "instanceId",
      26              :     // 4.22: a transient attribute instance carries its own expiry, and 4.8
      27              :     // makes it the fifth Temporal Property beside the four already here.
      28              :     "expiresAt",
      29              :     // 5.8.6 showChanges: one previous-member per attribute type.
      30              :     // `previousJson` holds an arbitrary JSON value (4.5.20), which a
      31              :     // sub-attribute walk would rewrite key by key.
      32              :     "previousValue",
      33              :     "previousObject",
      34              :     "previousLanguageMap",
      35              :     "previousJson",
      36              : ];
      37              : 
      38              : /// Compact an internal expanded entity for output.
      39        23138 : pub fn compact_entity(internal: &Value, ctx: &Context) -> Value {
      40        23138 :     let Some(obj) = internal.as_object() else {
      41            4 :         return internal.clone();
      42              :     };
      43        23134 :     let members = within_types(obj, ctx);
      44        23134 :     let mut out = Map::new();
      45        80962 :     for (k, v) in obj {
      46        80962 :         match k.as_str() {
      47        80962 :             "id" => {
      48        23058 :                 out.insert("id".into(), v.clone());
      49        23058 :             }
      50        57904 :             "type" => {
      51        22776 :                 out.insert("type".into(), compact_types(v, ctx));
      52        22776 :             }
      53        35128 :             "scope" => {
      54         1032 :                 out.insert("scope".into(), unwrap_single(v.clone()));
      55         1032 :             }
      56        34096 :             "createdAt" | "modifiedAt" | "deletedAt" | "expiresAt" => {
      57         5232 :                 out.insert(k.clone(), v.clone());
      58         5232 :             }
      59        28864 :             _ => {
      60        28864 :                 let term = members.compact_iri(k);
      61        28864 :                 out.insert(term, compact_attr_value(v, &ctx.within(k)));
      62        28864 :             }
      63              :         }
      64              :     }
      65        23134 :     Value::Object(out)
      66        23138 : }
      67              : 
      68              : /// Compact an expanded `@type` value: each IRI to its term, a one-element
      69              : /// array unwrapped to a string.
      70        25096 : pub fn compact_types(v: &Value, ctx: &Context) -> Value {
      71        25096 :     match v {
      72        25060 :         Value::Array(items) => unwrap_single(Value::Array(
      73        25060 :             items
      74        25060 :                 .iter()
      75        25402 :                 .map(|t| match t {
      76        25400 :                     Value::String(iri) => Value::String(ctx.compact_iri(iri)),
      77            2 :                     other => other.clone(),
      78        25402 :                 })
      79        25060 :                 .collect(),
      80              :         )),
      81           34 :         Value::String(iri) => Value::String(ctx.compact_iri(iri)),
      82            2 :         other => other.clone(),
      83              :     }
      84        25096 : }
      85              : 
      86        30084 : fn compact_attr_value(v: &Value, ctx: &Context) -> Value {
      87        30084 :     match v {
      88        30070 :         Value::Array(instances) => unwrap_single(Value::Array(
      89        30598 :             instances.iter().map(|i| compact_instance(i, ctx)).collect(),
      90              :         )),
      91           14 :         other => compact_instance(other, ctx),
      92              :     }
      93        30084 : }
      94              : 
      95              : /// Compact one attribute instance (public for temporal presentation, which
      96              : /// keeps instance arrays un-unwrapped).
      97        38224 : pub fn compact_instance(inst: &Value, ctx: &Context) -> Value {
      98        38224 :     let Some(obj) = inst.as_object() else {
      99           22 :         return inst.clone();
     100              :     };
     101        38202 :     let mut out = Map::new();
     102       110100 :     for (k, v) in obj {
     103       110100 :         if k == "vocab" || k == "previousVocab" {
     104              :             // vocab values compact back to terms
     105          124 :             let compacted = match v {
     106          110 :                 Value::String(iri) => Value::String(ctx.compact_iri(iri)),
     107           12 :                 Value::Array(a) => Value::Array(
     108           12 :                     a.iter()
     109           24 :                         .map(|s| match s {
     110           22 :                             Value::String(iri) => Value::String(ctx.compact_iri(iri)),
     111            2 :                             o => o.clone(),
     112           24 :                         })
     113           12 :                         .collect(),
     114              :                 ),
     115            2 :                 o => o.clone(),
     116              :             };
     117          124 :             out.insert(k.clone(), compacted);
     118       109976 :         } else if k == "objectType" || k == "valueType" {
     119           12 :             // both are type coerced on input (4.5.2.2), so terms on output
     120           12 :             out.insert(k.clone(), compact_types(v, ctx));
     121       109964 :         } else if k == "entityTypeSealed" {
     122           16 :             // 4.5.2.2 / annex B: @vocab-coerced — compacts back to a term
     123           16 :             // exactly like a type name; entityIdSealed needs no arm (a
     124           16 :             // plain string passes through the default member handling)
     125           16 :             out.insert("entityTypeSealed".into(), compact_types(v, ctx));
     126       109948 :         } else if k == "objectList" || k == "previousObjectList" {
     127              :             // 4.5.22.2: the normalized objectList is an ordered array of
     128              :             // JSON objects each "containing a single Attribute with a key
     129              :             // called "object"" — the internal form stores bare URIs.
     130           20 :             let wrapped = match v {
     131           18 :                 Value::Array(a) => Value::Array(
     132           18 :                     a.iter()
     133           30 :                         .map(|it| match it {
     134           28 :                             Value::String(uri) => serde_json::json!({ "object": uri }),
     135            2 :                             other => other.clone(),
     136           30 :                         })
     137           18 :                         .collect(),
     138              :                 ),
     139            2 :                 other => other.clone(),
     140              :             };
     141           20 :             out.insert(k.clone(), wrapped);
     142       109928 :         } else if VERBATIM.contains(&k.as_str()) {
     143       108708 :             out.insert(k.clone(), v.clone());
     144       108708 :         } else {
     145         1220 :             // sub-attribute
     146         1220 :             out.insert(ctx.compact_iri(k), compact_attr_value(v, &ctx.within(k)));
     147         1220 :         }
     148              :     }
     149        38202 :     Value::Object(out)
     150        38224 : }
     151              : 
     152              : /// Shallow compaction for simplified (keyValues) docs: rename top-level keys
     153              : /// and compact type values, leave attribute VALUES verbatim (they are plain
     154              : /// JSON — recursing would mangle e.g. single-ring polygons).
     155          330 : pub fn compact_entity_shallow(internal: &Value, ctx: &Context) -> Value {
     156          330 :     let Some(obj) = internal.as_object() else {
     157            2 :         return internal.clone();
     158              :     };
     159          328 :     let members = within_types(obj, ctx);
     160          328 :     let mut out = Map::new();
     161         1186 :     for (k, v) in obj {
     162         1186 :         match k.as_str() {
     163         1186 :             "id" | "createdAt" | "modifiedAt" | "deletedAt" | "expiresAt" => {
     164          346 :                 out.insert(k.clone(), v.clone());
     165          346 :             }
     166              :             // 4.5.1: a Scope, or an array only "in case of an Entity that has
     167              :             // multiple Scopes" — the same rule as the normalized form
     168          840 :             "scope" => {
     169            4 :                 out.insert("scope".into(), unwrap_single(v.clone()));
     170            4 :             }
     171          836 :             "type" => {
     172          322 :                 out.insert("type".into(), compact_types(v, ctx));
     173          322 :             }
     174          514 :             _ => {
     175          514 :                 out.insert(
     176          514 :                     members.compact_iri(k),
     177          514 :                     compact_simplified_value(v, &ctx.within(k)),
     178          514 :                 );
     179          514 :             }
     180              :         }
     181              :     }
     182          328 :     Value::Object(out)
     183          330 : }
     184              : 
     185              : /// 4.5.4 Simplified Representation: the VocabProperty form is the single-key
     186              : /// object {"vocab": …} (Example 6) whose IRI(s) compact back to terms, exactly
     187              : /// as on the normalized path; multi-instance attributes are the {"dataset":
     188              : /// `{<datasetId>|"@none": <simplified>}}` map (Example 2), compacted per
     189              : /// instance. All other simplified values are plain JSON and stay verbatim.
     190              : ///
     191              : /// Ceiling, and it belongs to the representation rather than to this
     192              : /// function: a Property whose VALUE is itself the single-key object
     193              : /// `{"vocab": …}` or `{"dataset": …}` is indistinguishable here from the
     194              : /// wrapper 4.5.4 gives those two forms, because the simplified document no
     195              : /// longer carries the attribute type that would tell them apart. The
     196              : /// normalized representation is lossless and is what a consumer that needs
     197              : /// the distinction asks for. A JsonProperty is NOT affected: 4.5.4 wraps its
     198              : /// payload as `{"json": …}`, so an arbitrary JSON value never reaches this
     199              : /// test unwrapped.
     200          566 : fn compact_simplified_value(v: &Value, ctx: &Context) -> Value {
     201          566 :     let Some(o) = v.as_object() else {
     202          474 :         return v.clone();
     203              :     };
     204           92 :     if o.len() == 1 {
     205           60 :         if let Some(vocab) = o.get("vocab") {
     206           12 :             let compacted = match vocab {
     207            8 :                 Value::String(iri) => Value::String(ctx.compact_iri(iri)),
     208            2 :                 Value::Array(a) => Value::Array(
     209            2 :                     a.iter()
     210            4 :                         .map(|s| match s {
     211            2 :                             Value::String(iri) => Value::String(ctx.compact_iri(iri)),
     212            2 :                             other => other.clone(),
     213            4 :                         })
     214            2 :                         .collect(),
     215              :                 ),
     216            2 :                 other => other.clone(),
     217              :             };
     218           12 :             return serde_json::json!({ "vocab": compacted });
     219           48 :         }
     220           48 :         if let Some(Value::Object(m)) = o.get("dataset") {
     221           22 :             let per_instance: Map<String, Value> = m
     222           22 :                 .iter()
     223           52 :                 .map(|(k, iv)| (k.clone(), compact_simplified_value(iv, ctx)))
     224           22 :                 .collect();
     225           22 :             return serde_json::json!({ "dataset": per_instance });
     226           26 :         }
     227           32 :     }
     228           58 :     v.clone()
     229          566 : }
     230              : 
     231              : /// `ctx` with the type-scoped core contexts of the Entity's types in force
     232              : /// for its own members (TS 104 175 Annex B), the reverse of expansion.
     233        23462 : fn within_types<'a>(entity: &Map<String, Value>, ctx: &'a Context) -> Cow<'a, Context> {
     234        23462 :     let types: Vec<&str> = match entity.get("type") {
     235        23090 :         Some(Value::Array(ts)) => ts.iter().filter_map(Value::as_str).collect(),
     236            8 :         Some(Value::String(t)) => vec![t.as_str()],
     237          364 :         _ => Vec::new(),
     238              :     };
     239        23462 :     ctx.within_types(&types)
     240        23462 : }
     241              : 
     242        56166 : fn unwrap_single(v: Value) -> Value {
     243        56166 :     match v {
     244        56166 :         Value::Array(mut items) if items.len() == 1 => items.remove(0),
     245          880 :         other => other,
     246              :     }
     247        56166 : }
     248              : 
     249              : #[cfg(test)]
     250              : mod tests {
     251              :     use super::*;
     252              :     use crate::expand::{expand_entity, ExpandOpts};
     253              :     use serde_json::json;
     254              : 
     255              :     const DEFAULT: &str = "https://uri.etsi.org/ngsi-ld/default-context/";
     256              : 
     257              :     /// TS 104 175 Annex B: a type-scoped core context applies to the
     258              :     /// members of a node of that type and not below it (type-scoped
     259              :     /// contexts do not propagate, JSON-LD 1.1 4.1.8); outside the type the
     260              :     /// same name expands through @vocab. Compaction is the reverse.
     261              :     #[test]
     262            2 :     fn a_type_scoped_term_expands_inside_its_type_only() {
     263            2 :         let ctx = std::sync::Arc::new(crate::core_context());
     264            2 :         let feature = json!({
     265            2 :             "id": "urn:ngsi-ld:Feature:1",
     266            2 :             "type": "Feature",
     267            2 :             "geometry": {"type": "Property", "value": "point",
     268            2 :                 "geometry": {"type": "Property", "value": "nested"}}
     269              :         });
     270            2 :         let out = expand_entity(feature.as_object().unwrap(), &ctx, ExpandOpts::default())
     271            2 :             .expect("expand");
     272            2 :         let attr = &out["https://purl.org/geojson/vocab#geometry"];
     273            2 :         assert!(attr.is_array(), "Feature scope: {out}");
     274            2 :         assert!(
     275            2 :             attr[0].get(&format!("{DEFAULT}geometry")).is_some(),
     276              :             "a sub-attribute is below the type scope: {out}"
     277              :         );
     278            2 :         assert_eq!(compact_entity(&out, &ctx), feature);
     279              : 
     280            2 :         let vehicle = json!({
     281            2 :             "id": "urn:ngsi-ld:Vehicle:1",
     282            2 :             "type": "Vehicle",
     283            2 :             "geometry": {"type": "Property", "value": "point"}
     284              :         });
     285            2 :         let out = expand_entity(vehicle.as_object().unwrap(), &ctx, ExpandOpts::default())
     286            2 :             .expect("expand");
     287            2 :         assert!(out.get(&format!("{DEFAULT}geometry")).is_some(), "{out}");
     288            2 :         assert_eq!(compact_entity(&out, &ctx), vehicle);
     289            2 :     }
     290              : 
     291              :     /// 4.5.2.2: valueType is type coerced into a datatype URI on input, so
     292              :     /// it compacts back on output: the entity reads back as it was written
     293              :     /// (Coraine value_type_subattr).
     294              :     #[test]
     295            2 :     fn a_value_type_round_trips() {
     296            2 :         let ctx = std::sync::Arc::new(crate::core_context());
     297            2 :         let e = json!({"id": "urn:E1", "type": "T",
     298            2 :             "P1": {"type": "Property", "value": 1, "valueType": "Integer"}});
     299            2 :         let out =
     300            2 :             expand_entity(e.as_object().unwrap(), &ctx, ExpandOpts::default()).expect("expand");
     301            2 :         assert_eq!(
     302            2 :             out[&format!("{DEFAULT}P1")][0]["valueType"],
     303            2 :             format!("{DEFAULT}Integer")
     304              :         );
     305            2 :         assert_eq!(compact_entity(&out, &ctx), e);
     306            2 :     }
     307              : 
     308              :     /// A property-scoped core context applies to everything inside the
     309              :     /// property, sub-attributes included (property-scoped contexts
     310              :     /// propagate), and compacts back to the scoped term.
     311              :     #[test]
     312            2 :     fn a_property_scoped_term_expands_inside_its_property() {
     313            2 :         let ctx = std::sync::Arc::new(crate::core_context());
     314            2 :         let doc = json!({
     315            2 :             "id": "urn:ngsi-ld:Device:1",
     316            2 :             "type": "Device",
     317            2 :             "endpoint": {"type": "Property", "value": "x",
     318            2 :                 "uri": {"type": "Property", "value": "http://a.example",
     319            2 :                     "uri": {"type": "Property", "value": "deeper"}}},
     320            2 :             "uri": {"type": "Property", "value": "top"}
     321              :         });
     322            2 :         let out =
     323            2 :             expand_entity(doc.as_object().unwrap(), &ctx, ExpandOpts::default()).expect("expand");
     324            2 :         let inner = &out["https://uri.etsi.org/ngsi-ld/endpoint"][0];
     325            2 :         let uri = &inner["https://uri.etsi.org/ngsi-ld/uri"][0];
     326            2 :         assert!(
     327            2 :             uri.get("https://uri.etsi.org/ngsi-ld/uri").is_some(),
     328              :             "{out}"
     329              :         );
     330            2 :         assert!(out.get(&format!("{DEFAULT}uri")).is_some(), "{out}");
     331            2 :         assert_eq!(compact_entity(&out, &ctx), doc);
     332            2 :     }
     333              : 
     334              :     #[test]
     335            2 :     fn round_trip_under_core_context() {
     336            2 :         let input = json!({
     337            2 :             "id": "urn:ngsi-ld:Building:1",
     338            2 :             "type": "Building",
     339            2 :             "name": {"type": "Property", "value": "Eiffel Tower"},
     340            2 :             "location": {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [2.29, 48.85]}}
     341              :         });
     342            2 :         let ctx = std::sync::Arc::new(crate::core_context());
     343            2 :         let expanded =
     344            2 :             expand_entity(input.as_object().unwrap(), &ctx, ExpandOpts::default()).unwrap();
     345            2 :         let compacted = compact_entity(&expanded, &ctx);
     346            2 :         assert_eq!(compacted, input);
     347              :         // input untouched by construction (&input); expanded untouched too
     348            2 :         assert_eq!(expanded["id"], "urn:ngsi-ld:Building:1");
     349            2 :     }
     350              : 
     351              :     /// 4.5.4 Example 6: simplified VocabProperty vocab IRIs compact to terms;
     352              :     /// dataset-map instances compact per instance (Example 9).
     353              :     #[test]
     354            2 :     fn simplified_vocab_compacts_to_term() {
     355            2 :         let ctx = std::sync::Arc::new(crate::core_context());
     356            2 :         let doc = json!({
     357            2 :             "id": "urn:ngsi-ld:V:1",
     358            2 :             "type": "https://uri.etsi.org/ngsi-ld/default-context/Vehicle",
     359            2 :             "https://uri.etsi.org/ngsi-ld/default-context/category":
     360            2 :                 {"vocab": "https://uri.etsi.org/ngsi-ld/default-context/non-commercial"},
     361            2 :             "https://uri.etsi.org/ngsi-ld/default-context/mixed":
     362            2 :                 {"dataset": {"@none": {"vocab": "https://uri.etsi.org/ngsi-ld/default-context/rental"}, "urn:ngsi-ld:Dataset:1": 7}}
     363              :         });
     364            2 :         let out = compact_entity_shallow(&doc, &ctx);
     365            2 :         assert_eq!(out["category"], json!({"vocab": "non-commercial"}));
     366            2 :         assert_eq!(
     367            2 :             out["mixed"],
     368            2 :             json!({"dataset": {"@none": {"vocab": "rental"}, "urn:ngsi-ld:Dataset:1": 7}})
     369              :         );
     370            2 :     }
     371              : 
     372              :     /// 4.5.22.2: normalized objectList round-trips — {"object": URI} entries
     373              :     /// in, bare URIs internally, {"object": URI} entries out.
     374              :     #[test]
     375            2 :     fn object_list_normalized_round_trip() {
     376            2 :         let input = json!({
     377            2 :             "id": "urn:ngsi-ld:B:1",
     378            2 :             "type": "T",
     379            2 :             "route": {"type": "ListRelationship",
     380            2 :                       "objectList": [{"object": "urn:ngsi-ld:R:1"}, "urn:ngsi-ld:R:2"]}
     381              :         });
     382            2 :         let ctx = std::sync::Arc::new(crate::core_context());
     383            2 :         let expanded =
     384            2 :             expand_entity(input.as_object().unwrap(), &ctx, ExpandOpts::default()).unwrap();
     385            2 :         let route = &expanded["https://uri.etsi.org/ngsi-ld/default-context/route"][0];
     386            2 :         assert_eq!(
     387            2 :             route["objectList"],
     388            2 :             json!(["urn:ngsi-ld:R:1", "urn:ngsi-ld:R:2"])
     389              :         );
     390            2 :         let compacted = compact_entity(&expanded, &ctx);
     391            2 :         assert_eq!(
     392            2 :             compacted["route"]["objectList"],
     393            2 :             json!([{"object": "urn:ngsi-ld:R:1"}, {"object": "urn:ngsi-ld:R:2"}])
     394              :         );
     395            2 :     }
     396              : 
     397           18 :     fn ctx_of(v: Value) -> crate::context::Context {
     398           18 :         let mut c = crate::context::Context::default();
     399           18 :         c.merge_object(v.as_object().unwrap()).unwrap();
     400           18 :         c.freeze();
     401           18 :         c
     402           18 :     }
     403              : 
     404              :     // ---- compact_entity -----------------------------------------------
     405              : 
     406              :     /// Nothing is silently dropped: an attribute IRI the context has no term
     407              :     /// for keeps its full IRI, and the member count is preserved.
     408              :     #[test]
     409            2 :     fn unmapped_attributes_survive_compaction() {
     410            2 :         let ctx = ctx_of(json!({"name": "https://example.org/name"}));
     411            2 :         let internal = json!({
     412            2 :             "id": "urn:ngsi-ld:B:1",
     413            2 :             "type": ["https://example.org/Building"],
     414            2 :             "https://example.org/name": [{"type": "Property", "value": "x"}],
     415            2 :             "https://elsewhere.example/unmapped": [{"type": "Property", "value": 1}]
     416              :         });
     417            2 :         let out = compact_entity(&internal, &ctx);
     418            2 :         let o = out.as_object().unwrap();
     419            2 :         assert_eq!(o.len(), 4, "no member may vanish: {out}");
     420            2 :         assert!(o.contains_key("name"));
     421            2 :         assert!(o.contains_key("https://elsewhere.example/unmapped"));
     422            2 :         assert!(!o.contains_key("https://example.org/name"));
     423              :         // single-element type array is unwrapped to a scalar
     424            2 :         assert_eq!(out["type"], json!("https://example.org/Building"));
     425            2 :     }
     426              : 
     427              :     /// Reserved entity members must never be overwritten by an attribute that
     428              :     /// would compact to the same name: the round-trip guard in compaction
     429              :     /// falls back to prefix compaction, so the attribute keeps a key of its
     430              :     /// own and the system members keep their values.
     431              :     #[test]
     432            2 :     fn reserved_members_are_not_clobbered_by_attributes() {
     433            2 :         let ctx = std::sync::Arc::new(crate::core_context());
     434            2 :         let vocab = "https://uri.etsi.org/ngsi-ld/default-context/";
     435            2 :         let mut internal = Map::new();
     436            2 :         internal.insert("id".into(), json!("urn:ngsi-ld:B:1"));
     437            2 :         internal.insert("type".into(), json!(format!("{vocab}Building")));
     438            6 :         for shadow in ["type", "id", "value"] {
     439            6 :             internal.insert(
     440            6 :                 format!("{vocab}{shadow}"),
     441            6 :                 json!([{"type": "Property", "value": "shadow"}]),
     442            6 :             );
     443            6 :         }
     444            2 :         let out = compact_entity(&Value::Object(internal), &ctx);
     445            2 :         assert_eq!(out["id"], "urn:ngsi-ld:B:1");
     446            2 :         assert_eq!(out["type"], "Building");
     447            2 :         assert_eq!(out.as_object().unwrap().len(), 5, "no member lost: {out}");
     448              :         // negative: not one of the three shadows may be rendered under its
     449              :         // bare reserved name — each keeps a key that expands back to itself.
     450            6 :         for shadow in ["type", "id", "value"] {
     451            6 :             let key = ctx.compact_iri(&format!("{vocab}{shadow}"));
     452            6 :             assert_ne!(key, shadow, "{shadow} was clobbered: {out}");
     453            6 :             assert_eq!(ctx.expand_key(&key), format!("{vocab}{shadow}"));
     454            6 :             assert_eq!(
     455            6 :                 out[&key]["value"], "shadow",
     456              :                 "{shadow} lost its value: {out}"
     457              :             );
     458              :         }
     459            2 :     }
     460              : 
     461              :     /// Non-object input is returned untouched; timestamps pass through
     462              :     /// verbatim; a single-element scope array is unwrapped.
     463              :     #[test]
     464            2 :     fn entity_edge_shapes() {
     465            2 :         let ctx = std::sync::Arc::new(crate::core_context());
     466            2 :         assert_eq!(
     467            2 :             compact_entity(&json!("not an object"), &ctx),
     468            2 :             json!("not an object")
     469              :         );
     470            2 :         assert_eq!(compact_entity(&json!([]), &ctx), json!([]));
     471            2 :         assert_eq!(compact_entity(&json!({}), &ctx), json!({}));
     472            2 :         let out = compact_entity(
     473            2 :             &json!({"id": "urn:x", "scope": ["/a"], "createdAt": "2026-01-01T00:00:00Z",
     474            2 :                     "modifiedAt": "2026-01-02T00:00:00Z", "deletedAt": "2026-01-03T00:00:00Z",
     475            2 :                     "expiresAt": "2026-01-04T00:00:00Z"}),
     476            2 :             &ctx,
     477              :         );
     478            2 :         assert_eq!(out["scope"], json!("/a"));
     479            2 :         assert_eq!(out["createdAt"], "2026-01-01T00:00:00Z");
     480            2 :         assert_eq!(out["expiresAt"], "2026-01-04T00:00:00Z");
     481            2 :         let out = compact_entity(&json!({"scope": ["/a", "/b"]}), &ctx);
     482            2 :         assert_eq!(out["scope"], json!(["/a", "/b"]));
     483            2 :     }
     484              : 
     485              :     /// 4.8 names five Temporal Properties — observedAt, createdAt,
     486              :     /// modifiedAt, deletedAt and expiresAt — and 4.22 puts `expiresAt` on an
     487              :     /// attribute instance, not only on the Entity. All five are reserved
     488              :     /// members whose value is a DateTime string, so none of them is a
     489              :     /// sub-attribute to be renamed under the request @context.
     490              :     ///
     491              :     /// 5.8.6 adds the showChanges previous-members, one per attribute type;
     492              :     /// `previousJson` carries an arbitrary JSON object (4.5.20), which a
     493              :     /// sub-attribute walk rewrites key by key.
     494              :     #[test]
     495            2 :     fn reserved_instance_members_are_not_compacted_as_sub_attributes() {
     496              :         // a request @context that renames both, which is what a member
     497              :         // treated as a sub-attribute name would obey
     498            2 :         let ctx = ctx_of(json!({
     499            2 :             "exp": "expiresAt",
     500            2 :             "pj": "previousJson",
     501            2 :             "inner": "https://example.org/inner"
     502              :         }));
     503              :         // a JSON value whose own keys happen to be IRIs the @context knows:
     504              :         // a sub-attribute walk rewrites them, which is data corruption, not
     505              :         // compaction — 4.5.20 keeps a JsonProperty value uninterpreted.
     506            2 :         let payload = json!({
     507            2 :             "https://example.org/inner": 1,
     508            2 :             "nested": {"https://example.org/inner": 2}
     509              :         });
     510            2 :         let inst = json!({
     511            2 :             "type": "JsonProperty",
     512            2 :             "json": payload,
     513            2 :             "previousJson": payload,
     514            2 :             "expiresAt": "2026-01-04T00:00:00Z"
     515              :         });
     516            2 :         let out = compact_instance(&inst, &ctx);
     517            2 :         assert_eq!(
     518            2 :             out["expiresAt"], "2026-01-04T00:00:00Z",
     519              :             "expiresAt renamed or rewritten: {out}"
     520              :         );
     521            2 :         assert_eq!(out["previousJson"], payload, "previousJson mangled: {out}");
     522              :         // and the JsonProperty's own value was already safe
     523            2 :         assert_eq!(out["json"], payload);
     524            2 :     }
     525              : 
     526              :     // ---- compact_types ------------------------------------------------
     527              : 
     528              :     #[test]
     529            2 :     fn compact_types_shapes() {
     530            2 :         let ctx = ctx_of(json!({"B": "https://example.org/B"}));
     531            2 :         assert_eq!(
     532            2 :             compact_types(&json!("https://example.org/B"), &ctx),
     533            2 :             json!("B")
     534              :         );
     535            2 :         assert_eq!(
     536            2 :             compact_types(&json!(["https://example.org/B"]), &ctx),
     537            2 :             json!("B")
     538              :         );
     539            2 :         assert_eq!(
     540            2 :             compact_types(
     541            2 :                 &json!(["https://example.org/B", "https://example.org/C"]),
     542            2 :                 &ctx
     543              :             ),
     544            2 :             json!(["B", "https://example.org/C"])
     545              :         );
     546              :         // an empty list stays a list, non-strings pass through unchanged
     547            2 :         assert_eq!(compact_types(&json!([]), &ctx), json!([]));
     548            2 :         assert_eq!(compact_types(&json!([42]), &ctx), json!(42));
     549            2 :         assert_eq!(compact_types(&json!(null), &ctx), json!(null));
     550            2 :     }
     551              : 
     552              :     // ---- compact_instance ---------------------------------------------
     553              : 
     554              :     /// Property values are opaque JSON: their inner keys must NOT be compacted
     555              :     /// even when they look like IRIs the context knows.
     556              :     #[test]
     557            2 :     fn property_values_stay_verbatim() {
     558            2 :         let ctx = ctx_of(json!({"name": "https://example.org/name"}));
     559            2 :         let inst = json!({
     560            2 :             "type": "Property",
     561            2 :             "value": {"https://example.org/name": "not an attribute", "nested": [1, 2]},
     562            2 :             "unitCode": "CEL",
     563            2 :             "observedAt": "2026-01-01T00:00:00Z",
     564            2 :             "https://example.org/name": [{"type": "Property", "value": "sub"}]
     565              :         });
     566            2 :         let out = compact_instance(&inst, &ctx);
     567            2 :         assert_eq!(out["value"], inst["value"], "value must not be rewritten");
     568            2 :         assert_eq!(out["unitCode"], "CEL");
     569              :         // the sub-attribute IRI, in contrast, does compact to its term
     570            2 :         assert_eq!(out["name"], json!({"type": "Property", "value": "sub"}));
     571            2 :         assert!(out
     572            2 :             .as_object()
     573            2 :             .unwrap()
     574            2 :             .get("https://example.org/name")
     575            2 :             .is_none());
     576            2 :     }
     577              : 
     578              :     /// vocab / previousVocab / objectType / entityTypeSealed all carry IRIs
     579              :     /// that compact back to terms; a non-object instance is returned as-is.
     580              :     #[test]
     581            2 :     fn vocab_and_type_members_compact() {
     582            2 :         let ctx = ctx_of(json!({"cat": "https://example.org/cat",
     583            2 :                                 "T": "https://example.org/T"}));
     584            2 :         let out = compact_instance(
     585            2 :             &json!({"type": "VocabProperty",
     586            2 :                     "vocab": ["https://example.org/cat", "https://elsewhere.example/x"],
     587            2 :                     "previousVocab": "https://example.org/cat",
     588            2 :                     "objectType": ["https://example.org/T"],
     589            2 :                     "entityTypeSealed": ["https://example.org/T"]}),
     590            2 :             &ctx,
     591              :         );
     592            2 :         assert_eq!(out["vocab"], json!(["cat", "https://elsewhere.example/x"]));
     593            2 :         assert_eq!(out["previousVocab"], json!("cat"));
     594            2 :         assert_eq!(out["objectType"], json!("T"));
     595            2 :         assert_eq!(out["entityTypeSealed"], json!("T"));
     596            2 :         assert_eq!(compact_instance(&json!(7), &ctx), json!(7));
     597            2 :         assert_eq!(compact_instance(&json!("s"), &ctx), json!("s"));
     598            2 :     }
     599              : 
     600              :     /// 4.5.22.2: bare URIs are wrapped, entries already in object form are not
     601              :     /// wrapped twice, and a non-array objectList passes through.
     602              :     #[test]
     603            2 :     fn object_list_wrapping_edges() {
     604            2 :         let ctx = std::sync::Arc::new(crate::core_context());
     605            2 :         let out = compact_instance(
     606            2 :             &json!({"type": "ListRelationship",
     607            2 :                     "objectList": ["urn:a", {"object": "urn:b"}],
     608            2 :                     "previousObjectList": ["urn:c"]}),
     609            2 :             &ctx,
     610              :         );
     611            2 :         assert_eq!(
     612            2 :             out["objectList"],
     613            2 :             json!([{"object": "urn:a"}, {"object": "urn:b"}])
     614              :         );
     615            2 :         assert_eq!(out["previousObjectList"], json!([{"object": "urn:c"}]));
     616            2 :         let out = compact_instance(&json!({"objectList": "urn:a"}), &ctx);
     617            2 :         assert_eq!(out["objectList"], json!("urn:a"));
     618            2 :     }
     619              : 
     620              :     /// Multi-instance attributes keep their array; a single instance is
     621              :     /// unwrapped; an empty instance array stays empty.
     622              :     #[test]
     623            2 :     fn attribute_instance_arrays() {
     624            2 :         let ctx = ctx_of(json!({"a": "https://example.org/a"}));
     625            2 :         let out = compact_entity(
     626            2 :             &json!({"https://example.org/a": [
     627            2 :                 {"type": "Property", "value": 1, "datasetId": "urn:d:1"},
     628            2 :                 {"type": "Property", "value": 2}]}),
     629            2 :             &ctx,
     630              :         );
     631            2 :         assert_eq!(out["a"].as_array().unwrap().len(), 2);
     632            2 :         let out = compact_entity(&json!({"https://example.org/a": []}), &ctx);
     633            2 :         assert_eq!(out["a"], json!([]));
     634            2 :     }
     635              : 
     636              :     // ---- compact_entity_shallow / compact_simplified_value ------------
     637              : 
     638              :     /// Simplified values are plain JSON and stay verbatim — a single-ring
     639              :     /// polygon must not be reshaped, and a two-member object is not mistaken
     640              :     /// for a VocabProperty.
     641              :     #[test]
     642            2 :     fn simplified_values_stay_verbatim() {
     643            2 :         let ctx = ctx_of(json!({"loc": "https://example.org/loc",
     644            2 :                                 "v": "https://example.org/v"}));
     645            2 :         let out = compact_entity_shallow(
     646            2 :             &json!({
     647            2 :                 "id": "urn:x", "type": "https://example.org/T",
     648            2 :                 "https://example.org/loc": {"type": "Polygon", "coordinates": [[[0,0],[1,0],[0,1],[0,0]]]},
     649            2 :                 "https://example.org/v": {"vocab": "https://example.org/loc", "extra": 1}
     650            2 :             }),
     651            2 :             &ctx,
     652              :         );
     653            2 :         assert_eq!(
     654            2 :             out["loc"],
     655            2 :             json!({"type": "Polygon", "coordinates": [[[0,0],[1,0],[0,1],[0,0]]]})
     656              :         );
     657            2 :         assert_eq!(
     658            2 :             out["v"],
     659            2 :             json!({"vocab": "https://example.org/loc", "extra": 1})
     660              :         );
     661            2 :         assert_eq!(compact_entity_shallow(&json!("x"), &ctx), json!("x"));
     662            2 :     }
     663              : 
     664              :     /// 4.5.1 (CIM 009 V1.9.1 p.47-48): "scope" is "a Scope ... or an
     665              :     /// unordered JSON array with multiple Scopes in case of an Entity that
     666              :     /// has multiple Scopes". A stored one-element union (append with
     667              :     /// noOverwrite, Coraine append_scope_union_single) is one Scope in
     668              :     /// keyValues as in normalized; two stay an array.
     669              :     #[test]
     670            2 :     fn simplified_single_scope_is_unwrapped() {
     671            2 :         let ctx = std::sync::Arc::new(crate::core_context());
     672            2 :         let out = compact_entity_shallow(&json!({"id": "urn:x", "scope": ["/x"]}), &ctx);
     673            2 :         assert_eq!(out["scope"], json!("/x"));
     674            2 :         let out = compact_entity_shallow(&json!({"id": "urn:x", "scope": ["/x", "/y"]}), &ctx);
     675            2 :         assert_eq!(out["scope"], json!(["/x", "/y"]));
     676            2 :     }
     677              : 
     678              :     /// vocab members that are not IRIs (a number in the array, an object
     679              :     /// instead of a string) pass through on both paths; an array of IRIs
     680              :     /// compacts element by element in the simplified form too.
     681              :     #[test]
     682            2 :     fn vocab_members_that_are_not_iris_stay_verbatim() {
     683            2 :         let ctx = ctx_of(json!({"cat": "https://example.org/cat"}));
     684            2 :         let out = compact_instance(
     685            2 :             &json!({"type": "VocabProperty",
     686            2 :                     "vocab": ["https://example.org/cat", 3],
     687            2 :                     "previousVocab": {"x": 1}}),
     688            2 :             &ctx,
     689              :         );
     690            2 :         assert_eq!(out["vocab"], json!(["cat", 3]));
     691            2 :         assert_eq!(out["previousVocab"], json!({"x": 1}));
     692            2 :         let out = compact_entity_shallow(
     693            2 :             &json!({"id": "urn:x", "type": "https://example.org/T",
     694            2 :                     "https://example.org/a": {"vocab": ["https://example.org/cat", 3]},
     695            2 :                     "https://example.org/b": {"vocab": {"x": 1}}}),
     696            2 :             &ctx,
     697              :         );
     698            2 :         assert_eq!(out["https://example.org/a"], json!({"vocab": ["cat", 3]}));
     699            2 :         assert_eq!(out["https://example.org/b"], json!({"vocab": {"x": 1}}));
     700            2 :     }
     701              : 
     702              :     /// A "dataset" member whose value is not an object is left alone.
     703              :     #[test]
     704            2 :     fn simplified_dataset_edges() {
     705            2 :         let ctx = std::sync::Arc::new(crate::core_context());
     706            2 :         let out = compact_entity_shallow(
     707            2 :             &json!({"https://uri.etsi.org/ngsi-ld/default-context/a": {"dataset": 5}}),
     708            2 :             &ctx,
     709              :         );
     710            2 :         assert_eq!(out["a"], json!({"dataset": 5}));
     711            2 :     }
     712              : 
     713              :     /// The mutual recursion compact_attr_value ↔ compact_instance (and the
     714              :     /// nested "dataset" recursion) is bounded by the JSON parser: serde_json
     715              :     /// refuses documents nested deeper than its own recursion limit, so an
     716              :     /// attacker cannot drive compaction to a stack overflow.
     717              :     #[test]
     718            2 :     fn nesting_depth_is_bounded_by_the_parser() {
     719            4 :         fn nest(levels: usize) -> String {
     720            4 :             let mut s = String::from("{\"type\":\"Property\",\"value\":1}");
     721          440 :             for _ in 0..levels {
     722          440 :                 s = format!("{{\"https://example.org/s\":[{{\"type\":\"Property\",\"value\":1,\"https://example.org/s\":[{s}]}}]}}");
     723          440 :             }
     724            4 :             s
     725            4 :         }
     726            2 :         assert!(
     727            2 :             serde_json::from_str::<Value>(&nest(200)).is_err(),
     728              :             "the parser must refuse deeply nested documents"
     729              :         );
     730            2 :         let ctx = ctx_of(json!({"s": "https://example.org/s"}));
     731            2 :         let doc: Value = serde_json::from_str(&nest(20)).expect("within the parser limit");
     732            2 :         let out = compact_entity(&doc, &ctx);
     733            2 :         assert!(out.get("s").is_some());
     734            2 :     }
     735              : }
        

Generated by: LCOV version 2.0-1