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