LCOV - code coverage report
Current view: top level - antares-jsonld/src - compact.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 97.4 % 379 369
Test Date: 2026-09-21 10:31:06 Functions: 90.7 % 43 39

            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              : 
       8              : /// Entity-instance members whose values stay verbatim during compaction.
       9              : const VERBATIM: &[&str] = &[
      10              :     "type",
      11              :     "value",
      12              :     "object",
      13              :     "datasetId",
      14              :     "observedAt",
      15              :     "unitCode",
      16              :     "lang",
      17              :     "languageMap",
      18              :     "json",
      19              :     "valueList",
      20              :     "objectList",
      21              :     "createdAt",
      22              :     "modifiedAt",
      23              :     "deletedAt",
      24              :     "instanceId",
      25              :     // 4.22: a transient attribute instance carries its own expiry, and 4.8
      26              :     // makes it the fifth Temporal Property beside the four already here.
      27              :     "expiresAt",
      28              :     // 5.8.6 showChanges: one previous-member per attribute type.
      29              :     // `previousJson` holds an arbitrary JSON value (4.5.20), which a
      30              :     // sub-attribute walk would rewrite key by key.
      31              :     "previousValue",
      32              :     "previousObject",
      33              :     "previousLanguageMap",
      34              :     "previousJson",
      35              : ];
      36              : 
      37              : /// Compact an internal expanded entity for output.
      38        13942 : pub fn compact_entity(internal: &Value, ctx: &Context) -> Value {
      39        13942 :     let Some(obj) = internal.as_object() else {
      40            4 :         return internal.clone();
      41              :     };
      42        13938 :     let mut out = Map::new();
      43        48224 :     for (k, v) in obj {
      44        48224 :         match k.as_str() {
      45        48224 :             "id" => {
      46        13906 :                 out.insert("id".into(), v.clone());
      47        13906 :             }
      48        34318 :             "type" => {
      49        13904 :                 out.insert("type".into(), compact_types(v, ctx));
      50        13904 :             }
      51        20414 :             "scope" => {
      52          870 :                 out.insert("scope".into(), unwrap_single(v.clone()));
      53          870 :             }
      54        19544 :             "createdAt" | "modifiedAt" | "deletedAt" | "expiresAt" => {
      55         4940 :                 out.insert(k.clone(), v.clone());
      56         4940 :             }
      57        14604 :             _ => {
      58        14604 :                 let term = ctx.compact_iri(k);
      59        14604 :                 out.insert(term, compact_attr_value(v, ctx));
      60        14604 :             }
      61              :         }
      62              :     }
      63        13938 :     Value::Object(out)
      64        13942 : }
      65              : 
      66              : /// Compact an expanded `@type` value: each IRI to its term, a one-element
      67              : /// array unwrapped to a string.
      68        14600 : pub fn compact_types(v: &Value, ctx: &Context) -> Value {
      69        14600 :     match v {
      70        14588 :         Value::Array(items) => unwrap_single(Value::Array(
      71        14588 :             items
      72        14588 :                 .iter()
      73        14596 :                 .map(|t| match t {
      74        14594 :                     Value::String(iri) => Value::String(ctx.compact_iri(iri)),
      75            2 :                     other => other.clone(),
      76        14596 :                 })
      77        14588 :                 .collect(),
      78              :         )),
      79           10 :         Value::String(iri) => Value::String(ctx.compact_iri(iri)),
      80            2 :         other => other.clone(),
      81              :     }
      82        14600 : }
      83              : 
      84        14738 : fn compact_attr_value(v: &Value, ctx: &Context) -> Value {
      85        14738 :     match v {
      86        14736 :         Value::Array(instances) => unwrap_single(Value::Array(
      87        14756 :             instances.iter().map(|i| compact_instance(i, ctx)).collect(),
      88              :         )),
      89            2 :         other => compact_instance(other, ctx),
      90              :     }
      91        14738 : }
      92              : 
      93              : /// Compact one attribute instance (public for temporal presentation, which
      94              : /// keeps instance arrays un-unwrapped).
      95        15922 : pub fn compact_instance(inst: &Value, ctx: &Context) -> Value {
      96        15922 :     let Some(obj) = inst.as_object() else {
      97           10 :         return inst.clone();
      98              :     };
      99        15912 :     let mut out = Map::new();
     100        47384 :     for (k, v) in obj {
     101        47384 :         if k == "vocab" || k == "previousVocab" {
     102              :             // vocab values compact back to terms
     103           28 :             let compacted = match v {
     104           26 :                 Value::String(iri) => Value::String(ctx.compact_iri(iri)),
     105            2 :                 Value::Array(a) => Value::Array(
     106            2 :                     a.iter()
     107            4 :                         .map(|s| match s {
     108            4 :                             Value::String(iri) => Value::String(ctx.compact_iri(iri)),
     109            0 :                             o => o.clone(),
     110            4 :                         })
     111            2 :                         .collect(),
     112              :                 ),
     113            0 :                 o => o.clone(),
     114              :             };
     115           28 :             out.insert(k.clone(), compacted);
     116        47356 :         } else if k == "objectType" {
     117            2 :             out.insert("objectType".into(), compact_types(v, ctx));
     118        47354 :         } else if k == "entityTypeSealed" {
     119            4 :             // 4.5.2.2 / annex B: @vocab-coerced — compacts back to a term
     120            4 :             // exactly like a type name; entityIdSealed needs no arm (a
     121            4 :             // plain string passes through the default member handling)
     122            4 :             out.insert("entityTypeSealed".into(), compact_types(v, ctx));
     123        47350 :         } else if k == "objectList" || k == "previousObjectList" {
     124              :             // 4.5.22.2: the normalized objectList is an ordered array of
     125              :             // JSON objects each "containing a single Attribute with a key
     126              :             // called "object"" — the internal form stores bare URIs.
     127           12 :             let wrapped = match v {
     128           10 :                 Value::Array(a) => Value::Array(
     129           10 :                     a.iter()
     130           18 :                         .map(|it| match it {
     131           16 :                             Value::String(uri) => serde_json::json!({ "object": uri }),
     132            2 :                             other => other.clone(),
     133           18 :                         })
     134           10 :                         .collect(),
     135              :                 ),
     136            2 :                 other => other.clone(),
     137              :             };
     138           12 :             out.insert(k.clone(), wrapped);
     139        47338 :         } else if VERBATIM.contains(&k.as_str()) {
     140        47204 :             out.insert(k.clone(), v.clone());
     141        47204 :         } else {
     142          134 :             // sub-attribute
     143          134 :             out.insert(ctx.compact_iri(k), compact_attr_value(v, ctx));
     144          134 :         }
     145              :     }
     146        15912 :     Value::Object(out)
     147        15922 : }
     148              : 
     149              : /// Shallow compaction for simplified (keyValues) docs: rename top-level keys
     150              : /// and compact type values, leave attribute VALUES verbatim (they are plain
     151              : /// JSON — recursing would mangle e.g. single-ring polygons).
     152           12 : pub fn compact_entity_shallow(internal: &Value, ctx: &Context) -> Value {
     153           12 :     let Some(obj) = internal.as_object() else {
     154            2 :         return internal.clone();
     155              :     };
     156           10 :     let mut out = Map::new();
     157           34 :     for (k, v) in obj {
     158           34 :         match k.as_str() {
     159           34 :             "id" | "scope" | "createdAt" | "modifiedAt" | "deletedAt" | "expiresAt" => {
     160            8 :                 out.insert(k.clone(), v.clone());
     161            8 :             }
     162           26 :             "type" => {
     163            8 :                 out.insert("type".into(), compact_types(v, ctx));
     164            8 :             }
     165           18 :             _ => {
     166           18 :                 out.insert(ctx.compact_iri(k), compact_simplified_value(v, ctx));
     167           18 :             }
     168              :         }
     169              :     }
     170           10 :     Value::Object(out)
     171           12 : }
     172              : 
     173              : /// 4.5.4 Simplified Representation: the VocabProperty form is the single-key
     174              : /// object {"vocab": …} (Example 6) whose IRI(s) compact back to terms, exactly
     175              : /// as on the normalized path; multi-instance attributes are the {"dataset":
     176              : /// `{<datasetId>|"@none": <simplified>}}` map (Example 2), compacted per
     177              : /// instance. All other simplified values are plain JSON and stay verbatim.
     178              : ///
     179              : /// Ceiling, and it belongs to the representation rather than to this
     180              : /// function: a Property whose VALUE is itself the single-key object
     181              : /// `{"vocab": …}` or `{"dataset": …}` is indistinguishable here from the
     182              : /// wrapper 4.5.4 gives those two forms, because the simplified document no
     183              : /// longer carries the attribute type that would tell them apart. The
     184              : /// normalized representation is lossless and is what a consumer that needs
     185              : /// the distinction asks for. A JsonProperty is NOT affected: 4.5.4 wraps its
     186              : /// payload as `{"json": …}`, so an arbitrary JSON value never reaches this
     187              : /// test unwrapped.
     188           22 : fn compact_simplified_value(v: &Value, ctx: &Context) -> Value {
     189           22 :     let Some(o) = v.as_object() else {
     190            6 :         return v.clone();
     191              :     };
     192           16 :     if o.len() == 1 {
     193           12 :         if let Some(vocab) = o.get("vocab") {
     194            4 :             let compacted = match vocab {
     195            4 :                 Value::String(iri) => Value::String(ctx.compact_iri(iri)),
     196            0 :                 Value::Array(a) => Value::Array(
     197            0 :                     a.iter()
     198            0 :                         .map(|s| match s {
     199            0 :                             Value::String(iri) => Value::String(ctx.compact_iri(iri)),
     200            0 :                             other => other.clone(),
     201            0 :                         })
     202            0 :                         .collect(),
     203              :                 ),
     204            0 :                 other => other.clone(),
     205              :             };
     206            4 :             return serde_json::json!({ "vocab": compacted });
     207            8 :         }
     208            8 :         if let Some(Value::Object(m)) = o.get("dataset") {
     209            2 :             let per_instance: Map<String, Value> = m
     210            2 :                 .iter()
     211            4 :                 .map(|(k, iv)| (k.clone(), compact_simplified_value(iv, ctx)))
     212            2 :                 .collect();
     213            2 :             return serde_json::json!({ "dataset": per_instance });
     214            6 :         }
     215            4 :     }
     216           10 :     v.clone()
     217           22 : }
     218              : 
     219        30194 : fn unwrap_single(v: Value) -> Value {
     220        30194 :     match v {
     221        30194 :         Value::Array(mut items) if items.len() == 1 => items.remove(0),
     222           38 :         other => other,
     223              :     }
     224        30194 : }
     225              : 
     226              : #[cfg(test)]
     227              : mod tests {
     228              :     use super::*;
     229              :     use crate::expand::{expand_entity, ExpandOpts};
     230              :     use crate::loader::Loader;
     231              :     use serde_json::json;
     232              : 
     233              :     #[test]
     234            2 :     fn round_trip_under_core_context() {
     235            2 :         let input = json!({
     236            2 :             "id": "urn:ngsi-ld:Building:1",
     237            2 :             "type": "Building",
     238            2 :             "name": {"type": "Property", "value": "Eiffel Tower"},
     239            2 :             "location": {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [2.29, 48.85]}}
     240              :         });
     241            2 :         let ctx = Loader::new().core();
     242            2 :         let expanded =
     243            2 :             expand_entity(input.as_object().unwrap(), &ctx, ExpandOpts::default()).unwrap();
     244            2 :         let compacted = compact_entity(&expanded, &ctx);
     245            2 :         assert_eq!(compacted, input);
     246              :         // input untouched by construction (&input); expanded untouched too
     247            2 :         assert_eq!(expanded["id"], "urn:ngsi-ld:Building:1");
     248            2 :     }
     249              : 
     250              :     /// 4.5.4 Example 6: simplified VocabProperty vocab IRIs compact to terms;
     251              :     /// dataset-map instances compact per instance (Example 9).
     252              :     #[test]
     253            2 :     fn simplified_vocab_compacts_to_term() {
     254            2 :         let ctx = Loader::new().core();
     255            2 :         let doc = json!({
     256            2 :             "id": "urn:ngsi-ld:V:1",
     257            2 :             "type": "https://uri.etsi.org/ngsi-ld/default-context/Vehicle",
     258            2 :             "https://uri.etsi.org/ngsi-ld/default-context/category":
     259            2 :                 {"vocab": "https://uri.etsi.org/ngsi-ld/default-context/non-commercial"},
     260            2 :             "https://uri.etsi.org/ngsi-ld/default-context/mixed":
     261            2 :                 {"dataset": {"@none": {"vocab": "https://uri.etsi.org/ngsi-ld/default-context/rental"}, "urn:ngsi-ld:Dataset:1": 7}}
     262              :         });
     263            2 :         let out = compact_entity_shallow(&doc, &ctx);
     264            2 :         assert_eq!(out["category"], json!({"vocab": "non-commercial"}));
     265            2 :         assert_eq!(
     266            2 :             out["mixed"],
     267            2 :             json!({"dataset": {"@none": {"vocab": "rental"}, "urn:ngsi-ld:Dataset:1": 7}})
     268              :         );
     269            2 :     }
     270              : 
     271              :     /// 4.5.22.2: normalized objectList round-trips — {"object": URI} entries
     272              :     /// in, bare URIs internally, {"object": URI} entries out.
     273              :     #[test]
     274            2 :     fn object_list_normalized_round_trip() {
     275            2 :         let input = json!({
     276            2 :             "id": "urn:ngsi-ld:B:1",
     277            2 :             "type": "T",
     278            2 :             "route": {"type": "ListRelationship",
     279            2 :                       "objectList": [{"object": "urn:ngsi-ld:R:1"}, "urn:ngsi-ld:R:2"]}
     280              :         });
     281            2 :         let ctx = Loader::new().core();
     282            2 :         let expanded =
     283            2 :             expand_entity(input.as_object().unwrap(), &ctx, ExpandOpts::default()).unwrap();
     284            2 :         let route = &expanded["https://uri.etsi.org/ngsi-ld/default-context/route"][0];
     285            2 :         assert_eq!(
     286            2 :             route["objectList"],
     287            2 :             json!(["urn:ngsi-ld:R:1", "urn:ngsi-ld:R:2"])
     288              :         );
     289            2 :         let compacted = compact_entity(&expanded, &ctx);
     290            2 :         assert_eq!(
     291            2 :             compacted["route"]["objectList"],
     292            2 :             json!([{"object": "urn:ngsi-ld:R:1"}, {"object": "urn:ngsi-ld:R:2"}])
     293              :         );
     294            2 :     }
     295              : 
     296           16 :     fn ctx_of(v: Value) -> crate::context::Context {
     297           16 :         let mut c = crate::context::Context::default();
     298           16 :         c.merge_object(v.as_object().unwrap()).unwrap();
     299           16 :         c.freeze();
     300           16 :         c
     301           16 :     }
     302              : 
     303              :     // ---- compact_entity -----------------------------------------------
     304              : 
     305              :     /// Nothing is silently dropped: an attribute IRI the context has no term
     306              :     /// for keeps its full IRI, and the member count is preserved.
     307              :     #[test]
     308            2 :     fn unmapped_attributes_survive_compaction() {
     309            2 :         let ctx = ctx_of(json!({"name": "https://example.org/name"}));
     310            2 :         let internal = json!({
     311            2 :             "id": "urn:ngsi-ld:B:1",
     312            2 :             "type": ["https://example.org/Building"],
     313            2 :             "https://example.org/name": [{"type": "Property", "value": "x"}],
     314            2 :             "https://elsewhere.example/unmapped": [{"type": "Property", "value": 1}]
     315              :         });
     316            2 :         let out = compact_entity(&internal, &ctx);
     317            2 :         let o = out.as_object().unwrap();
     318            2 :         assert_eq!(o.len(), 4, "no member may vanish: {out}");
     319            2 :         assert!(o.contains_key("name"));
     320            2 :         assert!(o.contains_key("https://elsewhere.example/unmapped"));
     321            2 :         assert!(!o.contains_key("https://example.org/name"));
     322              :         // single-element type array is unwrapped to a scalar
     323            2 :         assert_eq!(out["type"], json!("https://example.org/Building"));
     324            2 :     }
     325              : 
     326              :     /// Reserved entity members must never be overwritten by an attribute that
     327              :     /// would compact to the same name: the round-trip guard in compaction
     328              :     /// falls back to prefix compaction, so the attribute keeps a key of its
     329              :     /// own and the system members keep their values.
     330              :     #[test]
     331            2 :     fn reserved_members_are_not_clobbered_by_attributes() {
     332            2 :         let ctx = Loader::new().core();
     333            2 :         let vocab = "https://uri.etsi.org/ngsi-ld/default-context/";
     334            2 :         let mut internal = Map::new();
     335            2 :         internal.insert("id".into(), json!("urn:ngsi-ld:B:1"));
     336            2 :         internal.insert("type".into(), json!(format!("{vocab}Building")));
     337            6 :         for shadow in ["type", "id", "value"] {
     338            6 :             internal.insert(
     339            6 :                 format!("{vocab}{shadow}"),
     340            6 :                 json!([{"type": "Property", "value": "shadow"}]),
     341            6 :             );
     342            6 :         }
     343            2 :         let out = compact_entity(&Value::Object(internal), &ctx);
     344            2 :         assert_eq!(out["id"], "urn:ngsi-ld:B:1");
     345            2 :         assert_eq!(out["type"], "Building");
     346            2 :         assert_eq!(out.as_object().unwrap().len(), 5, "no member lost: {out}");
     347              :         // negative: not one of the three shadows may be rendered under its
     348              :         // bare reserved name — each keeps a key that expands back to itself.
     349            6 :         for shadow in ["type", "id", "value"] {
     350            6 :             let key = ctx.compact_iri(&format!("{vocab}{shadow}"));
     351            6 :             assert_ne!(key, shadow, "{shadow} was clobbered: {out}");
     352            6 :             assert_eq!(ctx.expand_key(&key), format!("{vocab}{shadow}"));
     353            6 :             assert_eq!(
     354            6 :                 out[&key]["value"], "shadow",
     355              :                 "{shadow} lost its value: {out}"
     356              :             );
     357              :         }
     358            2 :     }
     359              : 
     360              :     /// Non-object input is returned untouched; timestamps pass through
     361              :     /// verbatim; a single-element scope array is unwrapped.
     362              :     #[test]
     363            2 :     fn entity_edge_shapes() {
     364            2 :         let ctx = Loader::new().core();
     365            2 :         assert_eq!(
     366            2 :             compact_entity(&json!("not an object"), &ctx),
     367            2 :             json!("not an object")
     368              :         );
     369            2 :         assert_eq!(compact_entity(&json!([]), &ctx), json!([]));
     370            2 :         assert_eq!(compact_entity(&json!({}), &ctx), json!({}));
     371            2 :         let out = compact_entity(
     372            2 :             &json!({"id": "urn:x", "scope": ["/a"], "createdAt": "2026-01-01T00:00:00Z",
     373            2 :                     "modifiedAt": "2026-01-02T00:00:00Z", "deletedAt": "2026-01-03T00:00:00Z",
     374            2 :                     "expiresAt": "2026-01-04T00:00:00Z"}),
     375            2 :             &ctx,
     376              :         );
     377            2 :         assert_eq!(out["scope"], json!("/a"));
     378            2 :         assert_eq!(out["createdAt"], "2026-01-01T00:00:00Z");
     379            2 :         assert_eq!(out["expiresAt"], "2026-01-04T00:00:00Z");
     380            2 :         let out = compact_entity(&json!({"scope": ["/a", "/b"]}), &ctx);
     381            2 :         assert_eq!(out["scope"], json!(["/a", "/b"]));
     382            2 :     }
     383              : 
     384              :     /// 4.8 names five Temporal Properties — observedAt, createdAt,
     385              :     /// modifiedAt, deletedAt and expiresAt — and 4.22 puts `expiresAt` on an
     386              :     /// attribute instance, not only on the Entity. All five are reserved
     387              :     /// members whose value is a DateTime string, so none of them is a
     388              :     /// sub-attribute to be renamed under the request @context.
     389              :     ///
     390              :     /// 5.8.6 adds the showChanges previous-members, one per attribute type;
     391              :     /// `previousJson` carries an arbitrary JSON object (4.5.20), which a
     392              :     /// sub-attribute walk rewrites key by key.
     393              :     #[test]
     394            2 :     fn reserved_instance_members_are_not_compacted_as_sub_attributes() {
     395              :         // a request @context that renames both, which is what a member
     396              :         // treated as a sub-attribute name would obey
     397            2 :         let ctx = ctx_of(json!({
     398            2 :             "exp": "expiresAt",
     399            2 :             "pj": "previousJson",
     400            2 :             "inner": "https://example.org/inner"
     401              :         }));
     402              :         // a JSON value whose own keys happen to be IRIs the @context knows:
     403              :         // a sub-attribute walk rewrites them, which is data corruption, not
     404              :         // compaction — 4.5.20 keeps a JsonProperty value uninterpreted.
     405            2 :         let payload = json!({
     406            2 :             "https://example.org/inner": 1,
     407            2 :             "nested": {"https://example.org/inner": 2}
     408              :         });
     409            2 :         let inst = json!({
     410            2 :             "type": "JsonProperty",
     411            2 :             "json": payload,
     412            2 :             "previousJson": payload,
     413            2 :             "expiresAt": "2026-01-04T00:00:00Z"
     414              :         });
     415            2 :         let out = compact_instance(&inst, &ctx);
     416            2 :         assert_eq!(
     417            2 :             out["expiresAt"], "2026-01-04T00:00:00Z",
     418              :             "expiresAt renamed or rewritten: {out}"
     419              :         );
     420            2 :         assert_eq!(out["previousJson"], payload, "previousJson mangled: {out}");
     421              :         // and the JsonProperty's own value was already safe
     422            2 :         assert_eq!(out["json"], payload);
     423            2 :     }
     424              : 
     425              :     // ---- compact_types ------------------------------------------------
     426              : 
     427              :     #[test]
     428            2 :     fn compact_types_shapes() {
     429            2 :         let ctx = ctx_of(json!({"B": "https://example.org/B"}));
     430            2 :         assert_eq!(
     431            2 :             compact_types(&json!("https://example.org/B"), &ctx),
     432            2 :             json!("B")
     433              :         );
     434            2 :         assert_eq!(
     435            2 :             compact_types(&json!(["https://example.org/B"]), &ctx),
     436            2 :             json!("B")
     437              :         );
     438            2 :         assert_eq!(
     439            2 :             compact_types(
     440            2 :                 &json!(["https://example.org/B", "https://example.org/C"]),
     441            2 :                 &ctx
     442              :             ),
     443            2 :             json!(["B", "https://example.org/C"])
     444              :         );
     445              :         // an empty list stays a list, non-strings pass through unchanged
     446            2 :         assert_eq!(compact_types(&json!([]), &ctx), json!([]));
     447            2 :         assert_eq!(compact_types(&json!([42]), &ctx), json!(42));
     448            2 :         assert_eq!(compact_types(&json!(null), &ctx), json!(null));
     449            2 :     }
     450              : 
     451              :     // ---- compact_instance ---------------------------------------------
     452              : 
     453              :     /// Property values are opaque JSON: their inner keys must NOT be compacted
     454              :     /// even when they look like IRIs the context knows.
     455              :     #[test]
     456            2 :     fn property_values_stay_verbatim() {
     457            2 :         let ctx = ctx_of(json!({"name": "https://example.org/name"}));
     458            2 :         let inst = json!({
     459            2 :             "type": "Property",
     460            2 :             "value": {"https://example.org/name": "not an attribute", "nested": [1, 2]},
     461            2 :             "unitCode": "CEL",
     462            2 :             "observedAt": "2026-01-01T00:00:00Z",
     463            2 :             "https://example.org/name": [{"type": "Property", "value": "sub"}]
     464              :         });
     465            2 :         let out = compact_instance(&inst, &ctx);
     466            2 :         assert_eq!(out["value"], inst["value"], "value must not be rewritten");
     467            2 :         assert_eq!(out["unitCode"], "CEL");
     468              :         // the sub-attribute IRI, in contrast, does compact to its term
     469            2 :         assert_eq!(out["name"], json!({"type": "Property", "value": "sub"}));
     470            2 :         assert!(out
     471            2 :             .as_object()
     472            2 :             .unwrap()
     473            2 :             .get("https://example.org/name")
     474            2 :             .is_none());
     475            2 :     }
     476              : 
     477              :     /// vocab / previousVocab / objectType / entityTypeSealed all carry IRIs
     478              :     /// that compact back to terms; a non-object instance is returned as-is.
     479              :     #[test]
     480            2 :     fn vocab_and_type_members_compact() {
     481            2 :         let ctx = ctx_of(json!({"cat": "https://example.org/cat",
     482            2 :                                 "T": "https://example.org/T"}));
     483            2 :         let out = compact_instance(
     484            2 :             &json!({"type": "VocabProperty",
     485            2 :                     "vocab": ["https://example.org/cat", "https://elsewhere.example/x"],
     486            2 :                     "previousVocab": "https://example.org/cat",
     487            2 :                     "objectType": ["https://example.org/T"],
     488            2 :                     "entityTypeSealed": ["https://example.org/T"]}),
     489            2 :             &ctx,
     490              :         );
     491            2 :         assert_eq!(out["vocab"], json!(["cat", "https://elsewhere.example/x"]));
     492            2 :         assert_eq!(out["previousVocab"], json!("cat"));
     493            2 :         assert_eq!(out["objectType"], json!("T"));
     494            2 :         assert_eq!(out["entityTypeSealed"], json!("T"));
     495            2 :         assert_eq!(compact_instance(&json!(7), &ctx), json!(7));
     496            2 :         assert_eq!(compact_instance(&json!("s"), &ctx), json!("s"));
     497            2 :     }
     498              : 
     499              :     /// 4.5.22.2: bare URIs are wrapped, entries already in object form are not
     500              :     /// wrapped twice, and a non-array objectList passes through.
     501              :     #[test]
     502            2 :     fn object_list_wrapping_edges() {
     503            2 :         let ctx = Loader::new().core();
     504            2 :         let out = compact_instance(
     505            2 :             &json!({"type": "ListRelationship",
     506            2 :                     "objectList": ["urn:a", {"object": "urn:b"}],
     507            2 :                     "previousObjectList": ["urn:c"]}),
     508            2 :             &ctx,
     509              :         );
     510            2 :         assert_eq!(
     511            2 :             out["objectList"],
     512            2 :             json!([{"object": "urn:a"}, {"object": "urn:b"}])
     513              :         );
     514            2 :         assert_eq!(out["previousObjectList"], json!([{"object": "urn:c"}]));
     515            2 :         let out = compact_instance(&json!({"objectList": "urn:a"}), &ctx);
     516            2 :         assert_eq!(out["objectList"], json!("urn:a"));
     517            2 :     }
     518              : 
     519              :     /// Multi-instance attributes keep their array; a single instance is
     520              :     /// unwrapped; an empty instance array stays empty.
     521              :     #[test]
     522            2 :     fn attribute_instance_arrays() {
     523            2 :         let ctx = ctx_of(json!({"a": "https://example.org/a"}));
     524            2 :         let out = compact_entity(
     525            2 :             &json!({"https://example.org/a": [
     526            2 :                 {"type": "Property", "value": 1, "datasetId": "urn:d:1"},
     527            2 :                 {"type": "Property", "value": 2}]}),
     528            2 :             &ctx,
     529              :         );
     530            2 :         assert_eq!(out["a"].as_array().unwrap().len(), 2);
     531            2 :         let out = compact_entity(&json!({"https://example.org/a": []}), &ctx);
     532            2 :         assert_eq!(out["a"], json!([]));
     533            2 :     }
     534              : 
     535              :     // ---- compact_entity_shallow / compact_simplified_value ------------
     536              : 
     537              :     /// Simplified values are plain JSON and stay verbatim — a single-ring
     538              :     /// polygon must not be reshaped, and a two-member object is not mistaken
     539              :     /// for a VocabProperty.
     540              :     #[test]
     541            2 :     fn simplified_values_stay_verbatim() {
     542            2 :         let ctx = ctx_of(json!({"loc": "https://example.org/loc",
     543            2 :                                 "v": "https://example.org/v"}));
     544            2 :         let out = compact_entity_shallow(
     545            2 :             &json!({
     546            2 :                 "id": "urn:x", "type": "https://example.org/T",
     547            2 :                 "https://example.org/loc": {"type": "Polygon", "coordinates": [[[0,0],[1,0],[0,1],[0,0]]]},
     548            2 :                 "https://example.org/v": {"vocab": "https://example.org/loc", "extra": 1}
     549            2 :             }),
     550            2 :             &ctx,
     551              :         );
     552            2 :         assert_eq!(
     553            2 :             out["loc"],
     554            2 :             json!({"type": "Polygon", "coordinates": [[[0,0],[1,0],[0,1],[0,0]]]})
     555              :         );
     556            2 :         assert_eq!(
     557            2 :             out["v"],
     558            2 :             json!({"vocab": "https://example.org/loc", "extra": 1})
     559              :         );
     560            2 :         assert_eq!(compact_entity_shallow(&json!("x"), &ctx), json!("x"));
     561            2 :     }
     562              : 
     563              :     /// A "dataset" member whose value is not an object is left alone.
     564              :     #[test]
     565            2 :     fn simplified_dataset_edges() {
     566            2 :         let ctx = Loader::new().core();
     567            2 :         let out = compact_entity_shallow(
     568            2 :             &json!({"https://uri.etsi.org/ngsi-ld/default-context/a": {"dataset": 5}}),
     569            2 :             &ctx,
     570              :         );
     571            2 :         assert_eq!(out["a"], json!({"dataset": 5}));
     572            2 :     }
     573              : 
     574              :     /// The mutual recursion compact_attr_value ↔ compact_instance (and the
     575              :     /// nested "dataset" recursion) is bounded by the JSON parser: serde_json
     576              :     /// refuses documents nested deeper than its own recursion limit, so an
     577              :     /// attacker cannot drive compaction to a stack overflow.
     578              :     #[test]
     579            2 :     fn nesting_depth_is_bounded_by_the_parser() {
     580            4 :         fn nest(levels: usize) -> String {
     581            4 :             let mut s = String::from("{\"type\":\"Property\",\"value\":1}");
     582          440 :             for _ in 0..levels {
     583          440 :                 s = format!("{{\"https://example.org/s\":[{{\"type\":\"Property\",\"value\":1,\"https://example.org/s\":[{s}]}}]}}");
     584          440 :             }
     585            4 :             s
     586            4 :         }
     587            2 :         assert!(
     588            2 :             serde_json::from_str::<Value>(&nest(200)).is_err(),
     589              :             "the parser must refuse deeply nested documents"
     590              :         );
     591            2 :         let ctx = ctx_of(json!({"s": "https://example.org/s"}));
     592            2 :         let doc: Value = serde_json::from_str(&nest(20)).expect("within the parser limit");
     593            2 :         let out = compact_entity(&doc, &ctx);
     594            2 :         assert!(out.get("s").is_some());
     595            2 :     }
     596              : }
        

Generated by: LCOV version 2.0-1