Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! Representation transforms (6.3.7, 4.5.4, concise, sysAttrs, attrs
3 : //! projection, lang filter) — applied on the INTERNAL expanded form before
4 : //! compaction.
5 :
6 : use crate::state::AppState;
7 : use antares_jsonld::{compact_entity, compact_entity_shallow, Context};
8 : use antares_model::NgsiError;
9 : use antares_model::{is_meta, TenantId};
10 : use antares_store::Kind;
11 : use serde_json::{Map, Value};
12 : use std::collections::HashMap;
13 :
14 : #[derive(Debug, Default, Clone)]
15 : pub struct Repr {
16 : pub sys_attrs: bool,
17 : pub key_values: bool,
18 : pub concise: bool,
19 : /// expanded attribute IRIs to project (attrs=): entity meta stays.
20 : pub attrs: Option<Vec<String>>,
21 : /// pick= (4.21): STRICT projection — core members (id/type/scope/…) only
22 : /// survive when explicitly picked. Nodes may carry nested selections for
23 : /// linked entities.
24 : pub pick: Option<Vec<ProjNode>>,
25 : /// omit= (4.21): nodes WITHOUT children omit their head; nodes WITH
26 : /// children only constrain the linked entity below that head.
27 : pub omit: Option<Vec<ProjNode>>,
28 : /// The policy seam's own projection (ADR-0020), kept apart from the
29 : /// request's. A 4.21 projection belongs to ONE level — its nested
30 : /// selections describe the linked Entity below a head, so a bare `omit`
31 : /// name leaves that member alone on a joined document — while a
32 : /// narrowing is about what may be seen at all, and a member the subject
33 : /// may not see is no more visible one Relationship away. These travel
34 : /// down the join walk unchanged (`joined_repr`); `pick` and `omit` do
35 : /// not.
36 : pub policy_pick: Option<Vec<ProjNode>>,
37 : pub policy_omit: Option<Vec<ProjNode>>,
38 : pub lang: Option<String>,
39 : /// datasetId= instance filter; entry "@none" selects default instances.
40 : pub dataset_id: Option<Vec<String>>,
41 : }
42 :
43 1308 : pub fn parse_repr(params: &HashMap<String, String>, ctx: &Context) -> Result<Repr, NgsiError> {
44 1308 : let mut r = Repr::default();
45 1308 : let mut format: Option<String> = None;
46 1308 : if let Some(opts) = params.get("options") {
47 114 : for o in opts.split(',') {
48 114 : match o.trim() {
49 114 : "sysAttrs" => r.sys_attrs = true,
50 46 : "keyValues" | "simplified" => {
51 16 : format.get_or_insert("simplified".into());
52 16 : }
53 30 : "concise" => {
54 4 : format.get_or_insert("concise".into());
55 4 : }
56 26 : "normalized" => {
57 0 : format.get_or_insert("normalized".into());
58 0 : }
59 : _ => {
60 26 : return Err(NgsiError::InvalidRequest(format!(
61 26 : "unsupported options value {o:?}"
62 26 : )))
63 : }
64 : };
65 : }
66 1206 : }
67 : // format wins over options on conflict (6.3.7)
68 1282 : if let Some(f) = params.get("format") {
69 20 : match f.as_str() {
70 20 : "normalized" | "concise" | "simplified" | "keyValues" => {
71 8 : format = Some(if f == "keyValues" {
72 0 : "simplified".into()
73 : } else {
74 8 : f.clone()
75 : })
76 : }
77 : _ => {
78 12 : return Err(NgsiError::InvalidRequest(format!(
79 12 : "unsupported format value {f:?}"
80 12 : )))
81 : }
82 : }
83 1262 : }
84 1270 : match format.as_deref() {
85 12 : Some("simplified") => r.key_values = true,
86 4 : Some("concise") => r.concise = true,
87 1262 : _ => {}
88 : }
89 1270 : check_projection_exclusive(params)?;
90 1254 : if let Some(a) = params.get("attrs") {
91 54 : let mut list = Vec::new();
92 62 : for t in a.split(',') {
93 62 : let t = t.trim();
94 62 : if t.is_empty() || ENTITY_META.contains(&t) {
95 28 : return Err(NgsiError::BadRequestData(format!(
96 28 : "invalid attribute name {t:?} in attrs"
97 28 : )));
98 34 : }
99 34 : list.push(ctx.expand_key(t));
100 : }
101 26 : r.attrs = Some(list);
102 1200 : }
103 1226 : if let Some(pck) = params.get("pick") {
104 38 : r.pick = Some(parse_projection(pck, ctx)?);
105 1188 : }
106 1222 : if let Some(o) = params.get("omit") {
107 12 : r.omit = Some(parse_projection(o, ctx)?);
108 1210 : }
109 1218 : r.lang = params.get("lang").cloned();
110 1218 : r.dataset_id = params
111 1218 : .get("datasetId")
112 1218 : .map(|s| s.split(',').map(|d| d.trim().to_owned()).collect());
113 1218 : Ok(r)
114 1308 : }
115 :
116 : /// 4.5.5.1: the datasetId "is of datatype URI, or equal to the JSON-LD
117 : /// keyword `@none`", and "if no datasetId is provided, or `"datasetId":
118 : /// "@none"` is supplied, it is considered as the default Attribute
119 : /// instance". A default instance carries no datasetId of its own, so the
120 : /// write paths that select ONE instance by the `?datasetId=` parameter
121 : /// (5.6.5.4, 5.6.13.4) match `@none` against its absence. Instance members
122 : /// reach the same rule one layer earlier, in 5.5.7 expansion.
123 108 : pub fn target_dataset_id(params: &HashMap<String, String>) -> Option<&str> {
124 108 : params
125 108 : .get("datasetId")
126 108 : .map(String::as_str)
127 108 : .filter(|d| *d != "@none")
128 108 : }
129 :
130 : /// Narrow a representation by a policy decision (ADR-0020).
131 : ///
132 : /// `omit` is appended to whatever the request asked to omit: removing more
133 : /// members can only remove more. `pick` intersects with the request's,
134 : /// because a pick that added a name would serve a member the request did
135 : /// not ask for and the engine's narrowing would have widened the answer.
136 : ///
137 : /// The Entity frame survives a policy pick. 6.5.3.1 makes `pick` reduce an
138 : /// Entity "down to only contain the listed Entity members", and a client
139 : /// that wants `id` back names it; but the engine is restricting what may be
140 : /// seen rather than choosing a representation, and 5.2.4 makes `id` and
141 : /// `type` the Entity — a document without them is not one.
142 808 : pub fn narrow_projection(
143 808 : pick: &mut Option<Vec<ProjNode>>,
144 808 : omit: &mut Option<Vec<ProjNode>>,
145 808 : f: &crate::policy::Filter,
146 808 : ctx: &Context,
147 808 : ) -> Result<(), NgsiError> {
148 808 : if !f.omit.is_empty() {
149 6 : let mut nodes = policy_nodes(&f.omit);
150 6 : match omit {
151 0 : Some(own) => own.append(&mut nodes),
152 6 : None => *omit = Some(nodes),
153 : }
154 802 : }
155 808 : if !f.pick.is_empty() {
156 0 : let allowed = policy_nodes(&f.pick);
157 0 : let mut kept = match pick.take() {
158 0 : None => allowed,
159 0 : Some(own) => own
160 0 : .into_iter()
161 0 : .filter(|n| allowed.iter().any(|a| a.iri == n.iri))
162 0 : .collect(),
163 : };
164 0 : for frame in parse_projection("id,type", ctx)? {
165 0 : if !kept.iter().any(|n| n.raw == frame.raw) {
166 0 : kept.push(frame);
167 0 : }
168 : }
169 0 : *pick = Some(kept);
170 808 : }
171 808 : Ok(())
172 808 : }
173 :
174 : /// The same narrowing on a current-state [`Repr`], which is the one a join
175 : /// walk descends. It goes into the policy fields rather than into the
176 : /// request's own projection, so every document the answer carries is
177 : /// narrowed and not only the one at the top: `?join=flat` returns Entities
178 : /// reached over a Relationship, and a member the subject may not see is not
179 : /// less hidden for being one hop away.
180 : ///
181 : /// The Entity frame survives a policy pick — 5.2.4 makes `id` and `type`
182 : /// the Entity, and a document without them is not one — which is why the
183 : /// policy pick is applied to Attributes and never to the core members.
184 1194 : pub fn narrow_repr(r: &mut Repr, f: &crate::policy::Filter) {
185 1194 : if !f.omit.is_empty() {
186 14 : r.policy_omit = Some(policy_nodes(&f.omit));
187 1180 : }
188 1194 : if !f.pick.is_empty() {
189 4 : r.policy_pick = Some(policy_nodes(&f.pick));
190 1190 : }
191 1194 : }
192 :
193 : /// The same `pick`/`omit` names in the form an already-compacted document
194 : /// carries them. The query path folds a policy projection into the
195 : /// request's own representation, where the names are IRIs; a notification
196 : /// is compacted by `notify::build_data` long before the seam sees it, so
197 : /// there the names have to travel the other way — parsed against the same
198 : /// `@context` the document was compacted with, then compacted back. An
199 : /// engine writes one rule set either way, and a rule written as an IRI
200 : /// (which is what ADR-0020 asks of an engine) removes the member it names
201 : /// rather than silently matching nothing.
202 6 : pub fn compacted_filter(f: &crate::policy::Filter, ctx: &Context) -> crate::policy::Filter {
203 12 : let names = |raw: &[String]| -> Vec<String> {
204 12 : policy_nodes(raw)
205 12 : .iter()
206 12 : .map(|n| ctx.compact_iri(&n.iri))
207 12 : .collect()
208 12 : };
209 6 : crate::policy::Filter {
210 6 : pick: names(&f.pick),
211 6 : omit: names(&f.omit),
212 6 : ..f.clone()
213 6 : }
214 6 : }
215 :
216 : /// The `@context` a policy name is read in. Deliberately NOT the request's:
217 : /// [`antares_jsonld::Context::expand_key`] consults the term map before it
218 : /// decides a name is already an IRI, so a caller that binds the term a rule
219 : /// names — or binds the rule's own IRI as a term, which its own inline
220 : /// `@context` is enough to do — would move the rule off its target and walk
221 : /// out of the narrowing. A deployment's rule means the same thing whatever
222 : /// the caller sends. ADR-0020 asks an engine to write its rules as IRIs and
223 : /// those pass through unchanged; a short name is read here the way a name a
224 : /// request does not define is read anywhere else.
225 : static POLICY_CONTEXT: std::sync::LazyLock<Context> =
226 : std::sync::LazyLock::new(antares_jsonld::core_context);
227 :
228 : /// A policy's `pick`/`omit` names as projection nodes, expanded against
229 : /// [`POLICY_CONTEXT`].
230 : ///
231 : /// Deliberately NOT the 4.21 parser: 6.5.3.1 lets those members name `"id"`,
232 : /// `"type"`, `"scope"` or one projected Attribute, and ADR-0020 asks an
233 : /// engine to write its rules against IRIs — while 4.21 reads a dot as the
234 : /// sub-attribute path separator, so `https://uri.etsi.org/…/colour` through
235 : /// that grammar becomes the member `https://uri` and removes nothing. An
236 : /// engine names one member; it is expanded, and both forms of the name are
237 : /// kept so the projection matches a document whichever form its keys are in.
238 36 : fn policy_nodes(names: &[String]) -> Vec<ProjNode> {
239 36 : names
240 36 : .iter()
241 36 : .map(|n| ProjNode {
242 30 : raw: n.clone(),
243 30 : iri: POLICY_CONTEXT.expand_key(n),
244 30 : children: None,
245 30 : })
246 36 : .collect()
247 36 : }
248 :
249 : /// Maximum `{…}` selection depth of a projection tree — the number of
250 : /// Linked Entity hops it implies (5.7.1.4: must not exceed joinLevel).
251 152 : pub fn proj_depth(nodes: &[ProjNode]) -> usize {
252 152 : nodes
253 152 : .iter()
254 174 : .map(|n| match &n.children {
255 60 : Some(c) => 1 + proj_depth(c),
256 114 : None => 0,
257 174 : })
258 152 : .max()
259 152 : .unwrap_or(0)
260 152 : }
261 :
262 : /// The entity-level members of 4.5.1: everything an Entity carries that is
263 : /// NOT an Attribute. Every layer that has to tell the two apart reads this
264 : /// one list — `attrs`/`pick`/`omit` validation and projection here, the
265 : /// notification diff and tombstone in `notify`, the 4.3.6.8 amendment in
266 : /// `conformance`, the registration-scope narrowing in `federation`. A layer
267 : /// with its own copy is a layer that will disagree with the others about
268 : /// what an attribute is.
269 : pub(crate) const ENTITY_META: &[&str] = antares_model::ENTITY_META_KEYS;
270 :
271 : /// One node of a 4.21 attribute-projection expression; `children` carries a
272 : /// nested `{…}` selection (applied to linked entities on join).
273 : #[derive(Debug, Clone)]
274 : pub struct ProjNode {
275 : pub raw: String,
276 : pub iri: String,
277 : pub children: Option<Vec<ProjNode>>,
278 : }
279 :
280 : /// Parse + validate a pick=/omit= value (4.21) into a projection tree.
281 310 : pub(crate) fn parse_projection(s: &str, ctx: &Context) -> Result<Vec<ProjNode>, NgsiError> {
282 310 : let bad = || NgsiError::BadRequestData(format!("invalid attribute projection {s:?} (4.21)"));
283 : // Each `{…}` level is one Linked Entity hop (5.7.1.4), so a selection
284 : // deeper than the joinLevel ceiling can never be satisfied. Bounding it
285 : // here, before the recursive descent below, is what keeps the recursion
286 : // finite: pick=/omit= reach this parser as plain STRINGS — from the URI,
287 : // or from inside a query body, where the JSON nesting wall never sees
288 : // their braces.
289 310 : if s.is_empty()
290 302 : || s.matches('{').count() != s.matches('}').count()
291 294 : || crate::bounds::json_depth(s.as_bytes()) > crate::bounds::MAX_JOIN_LEVEL
292 286 : || !s
293 286 : .chars()
294 2234 : .all(|c| c.is_ascii_alphanumeric() || "_,.:{}#/%-+@|".contains(c))
295 : {
296 48 : return Err(bad());
297 262 : }
298 402 : fn split_top(s: &str) -> Option<Vec<&str>> {
299 402 : let mut out = Vec::new();
300 402 : let mut depth = 0usize;
301 402 : let mut start = 0usize;
302 3254 : for (i, c) in s.char_indices() {
303 50 : match c {
304 328 : '{' => depth += 1,
305 328 : '}' => depth = depth.checked_sub(1)?,
306 : // 4.21 orOp = | / , — both split at the same depth
307 90 : ',' | '|' if depth == 0 => {
308 90 : out.push(&s[start..i]);
309 90 : start = i + 1;
310 90 : }
311 2508 : _ => {}
312 : }
313 : }
314 402 : out.push(&s[start..]);
315 402 : Some(out)
316 402 : }
317 402 : fn parse_nodes(s: &str, ctx: &Context) -> Result<Vec<ProjNode>, NgsiError> {
318 402 : let bad = |m: &str| NgsiError::BadRequestData(format!("invalid attribute projection: {m}"));
319 402 : let parts = split_top(s).ok_or_else(|| bad("unbalanced braces"))?;
320 402 : let mut out: Vec<ProjNode> = Vec::new();
321 472 : for t in parts {
322 472 : let t = t.trim();
323 472 : if t.is_empty() {
324 28 : return Err(bad("empty projection member"));
325 444 : }
326 444 : let (head_part, children) = match t.find('{') {
327 140 : Some(i) => {
328 140 : let inner = t[i + 1..]
329 140 : .strip_suffix('}')
330 140 : .ok_or_else(|| bad("unclosed brace"))?;
331 140 : (&t[..i], Some(parse_nodes(inner, ctx)?))
332 : }
333 304 : None => (t, None),
334 : };
335 440 : let head = head_part.split('.').next().unwrap_or(head_part);
336 440 : if head.is_empty() || head_part.split('.').any(str::is_empty) {
337 8 : return Err(bad("empty path segment"));
338 432 : }
339 432 : if !head
340 432 : .chars()
341 432 : .next()
342 432 : .is_some_and(|c| c.is_ascii_alphanumeric() || "_:@#".contains(c))
343 : {
344 0 : return Err(bad("projection member starts with a special character"));
345 432 : }
346 432 : if out.iter().any(|n| n.raw == head) {
347 0 : return Err(bad("duplicate projection member"));
348 432 : }
349 432 : out.push(ProjNode {
350 432 : raw: head.to_owned(),
351 432 : iri: ctx.expand_key(head),
352 432 : children,
353 432 : });
354 : }
355 362 : Ok(out)
356 402 : }
357 262 : parse_nodes(s, ctx)
358 310 : }
359 :
360 : /// Apply the representation to an internal doc, producing a new internal doc
361 : /// 4.21 Projections: "pick, omit and attrs are mutually exclusive" — the one
362 : /// reading, so an operation cannot accept a combination another rejects.
363 2142 : pub fn check_projection_exclusive(params: &HashMap<String, String>) -> Result<(), NgsiError> {
364 2142 : let excl = ["pick", "omit", "attrs"]
365 2142 : .iter()
366 6426 : .filter(|k| params.contains_key(**k))
367 2142 : .count();
368 2142 : if excl > 1 {
369 32 : return Err(NgsiError::BadRequestData(
370 32 : "pick, omit and attrs are mutually exclusive (4.21)".into(),
371 32 : ));
372 2110 : }
373 2110 : Ok(())
374 2142 : }
375 :
376 : /// ADR-0020: does an Attribute survive the policy's own projection? Both
377 : /// forms of the name are tried, because a document reaches this function
378 : /// expanded on the query path and compacted on the notification path, and a
379 : /// rule names one Attribute either way.
380 16698 : pub fn policy_projected(pick: Option<&[ProjNode]>, omit: Option<&[ProjNode]>, k: &str) -> bool {
381 16698 : let names = |n: &ProjNode| n.iri == *k || n.raw == *k;
382 16698 : if let Some(pick) = pick {
383 6 : if !pick.iter().any(names) {
384 4 : return false;
385 2 : }
386 16692 : }
387 16694 : if let Some(omit) = omit {
388 40 : if omit.iter().any(names) {
389 20 : return false;
390 20 : }
391 16654 : }
392 16674 : true
393 16698 : }
394 :
395 : /// 4.21: does a core Entity member (id, type, scope, the system temporal
396 : /// Properties) survive the projection? `pick` constrains core members
397 : /// strictly — only what is named survives; `omit` drops a named member only
398 : /// when the node carries no children, because a node with children
399 : /// constrains the linked Entity below the head, not the head itself. The
400 : /// current-state and temporal representations project core members by the
401 : /// same rule and differ only below it.
402 68880 : pub fn meta_projected(pick: Option<&[ProjNode]>, omit: Option<&[ProjNode]>, k: &str) -> bool {
403 68880 : if let Some(pick) = pick {
404 116 : if !pick.iter().any(|n| n.raw == *k) {
405 64 : return false;
406 36 : }
407 68780 : }
408 68816 : if let Some(omit) = omit {
409 116 : if omit.iter().any(|n| n.raw == *k && n.children.is_none()) {
410 4 : return false;
411 112 : }
412 68700 : }
413 68812 : true
414 68880 : }
415 :
416 : /// ready for compaction.
417 13982 : pub fn apply(doc: &Value, r: &Repr) -> Value {
418 13982 : let Some(obj) = doc.as_object() else {
419 0 : return doc.clone();
420 : };
421 13982 : let mut out = Map::new();
422 62244 : for (k, v) in obj {
423 62244 : let is_meta = ENTITY_META.contains(&k.as_str());
424 62244 : if is_meta {
425 47534 : match k.as_str() {
426 : // 6.3.11 Table 6.3.11-1: expiresAt is a system temporal
427 : // attribute — included only when options=sysAttrs.
428 47534 : "createdAt" | "modifiedAt" | "expiresAt" if !r.sys_attrs => continue,
429 33738 : _ => {}
430 : }
431 : // the policy pick never reaches a core member: 5.2.4 makes id
432 : // and type the Entity, so a narrowing that removed them would
433 : // answer something that is not one
434 33738 : if !meta_projected(r.pick.as_deref(), r.omit.as_deref(), k)
435 33690 : || !meta_projected(None, r.policy_omit.as_deref(), k)
436 : {
437 48 : continue;
438 33690 : }
439 33690 : out.insert(k.clone(), v.clone());
440 33690 : continue;
441 14710 : }
442 14710 : if let Some(keep) = &r.attrs {
443 4 : if !keep.contains(k) {
444 2 : continue;
445 2 : }
446 14706 : }
447 14708 : if let Some(pick) = &r.pick {
448 104 : if !pick.iter().any(|n| n.iri == *k) {
449 24 : continue;
450 40 : }
451 14644 : }
452 14684 : if let Some(drop) = &r.omit {
453 44 : if drop.iter().any(|n| n.iri == *k && n.children.is_none()) {
454 18 : continue;
455 26 : }
456 14640 : }
457 14666 : if !policy_projected(r.policy_pick.as_deref(), r.policy_omit.as_deref(), k) {
458 22 : continue;
459 14644 : }
460 14644 : let raw: Vec<Value> = v.as_array().cloned().unwrap_or_else(|| vec![v.clone()]);
461 14644 : let kept: Vec<&Value> = raw
462 14644 : .iter()
463 14704 : .filter(|inst| match (&r.dataset_id, inst.get("datasetId")) {
464 14680 : (None, _) => true,
465 12 : (Some(want), Some(Value::String(have))) => want.iter().any(|w| w == have),
466 12 : (Some(want), None) => want.iter().any(|w| w == "@none"),
467 0 : _ => false,
468 14704 : })
469 14644 : .collect();
470 14644 : let mut instances: Vec<Value> = kept
471 14644 : .iter()
472 14688 : .map(|inst| transform_instance(inst, r))
473 14644 : .collect();
474 14644 : if instances.is_empty() {
475 4 : continue;
476 14640 : }
477 14640 : if r.key_values {
478 20 : if instances.len() == 1 {
479 : // 4.5.4: a lone instance simplifies to its bare value
480 12 : out.extend(instances.pop().map(|one| (k.clone(), one)));
481 : } else {
482 : // 4.5.4 multi-attribute case: a "dataset" map holding one
483 : // key-value pair for each datasetId, "@none" for the default
484 : // instance. The pairing below is positional, so `kept` and
485 : // `instances` must both still be whole here.
486 8 : let mut ds = Map::new();
487 20 : for (orig, simple) in kept.iter().zip(instances.iter()) {
488 20 : let key = orig
489 20 : .get("datasetId")
490 20 : .and_then(Value::as_str)
491 20 : .unwrap_or("@none");
492 20 : ds.insert(key.to_owned(), simple.clone());
493 20 : }
494 8 : out.insert(
495 8 : k.clone(),
496 8 : serde_json::json!({ "dataset": Value::Object(ds) }),
497 : );
498 : }
499 14620 : } else {
500 14620 : out.insert(k.clone(), Value::Array(instances));
501 14620 : }
502 : }
503 13982 : Value::Object(out)
504 13982 : }
505 :
506 14764 : fn transform_instance(inst: &Value, r: &Repr) -> Value {
507 14764 : let Some(obj) = inst.as_object() else {
508 0 : return inst.clone();
509 : };
510 : // lang filter first: LanguageProperty → Property under a selected language
511 14764 : let mut obj = obj.clone();
512 14764 : if let Some(lang) = &r.lang {
513 4 : apply_lang(&mut obj, lang);
514 14760 : }
515 :
516 14764 : if r.key_values {
517 32 : return simplified_value(&obj);
518 14732 : }
519 :
520 14732 : let mut out = Map::new();
521 58600 : for (k, v) in &obj {
522 58600 : match k.as_str() {
523 : // 6.3.11: expiresAt shares the sysAttrs gate on attribute
524 : // instances (current-state and temporal alike).
525 58600 : "createdAt" | "modifiedAt" | "expiresAt" if !r.sys_attrs => continue,
526 29842 : "type" if r.concise => continue,
527 42852 : _ => {}
528 : }
529 : // sub-attributes recurse
530 42852 : if let Some(arr) = v.as_array().filter(|_| !is_reserved_member(k)) {
531 72 : let subs: Vec<Value> = arr.iter().map(|i| transform_instance(i, r)).collect();
532 72 : out.insert(k.clone(), Value::Array(subs));
533 42780 : } else {
534 42780 : out.insert(k.clone(), v.clone());
535 42780 : }
536 : }
537 14732 : if r.concise {
538 : // bare-value collapse: a Property with only `value` collapses
539 48 : if out.len() == 1 {
540 12 : if let Some(v) = out.get("value") {
541 8 : return v.clone();
542 4 : }
543 36 : }
544 14684 : }
545 14724 : Value::Object(out)
546 14764 : }
547 :
548 : /// 4.15 Language Filter on one attribute instance (5.7.2.5, and the `lang`
549 : /// rows of Tables 6.18.3.2-1 / 6.19.3.1 for the temporal forms): a
550 : /// LanguageProperty "shall be converted into a Property" holding the chosen
551 : /// languageMap entry, with the non-reified `lang` member naming it.
552 76 : pub(crate) fn apply_lang(obj: &mut Map<String, Value>, lang: &str) {
553 76 : if obj.get("type").and_then(Value::as_str) != Some("LanguageProperty") {
554 24 : return;
555 52 : }
556 52 : let Some(lm) = obj.get("languageMap").and_then(Value::as_object) else {
557 0 : return;
558 : };
559 52 : if let Some((chosen_lang, value)) = select_lang(lm, lang) {
560 52 : obj.remove("languageMap");
561 52 : obj.insert("type".into(), Value::String("Property".into()));
562 52 : obj.insert("value".into(), value);
563 52 : obj.insert("lang".into(), Value::String(chosen_lang));
564 52 : }
565 76 : }
566 :
567 : /// 4.15 Language Filter: pick one languageMap entry for a lang priority
568 : /// list. Ranges are ordered by their q weights (RFC 3282, default 1, list
569 : /// position breaking ties); tags compare case-insensitively (RFC 5646); a
570 : /// range matches an exact tag, then a longer tag by prefix (fr → fr-CH),
571 : /// then a shorter tag by truncation (fr-CH → fr). "*" — or no match at
572 : /// all — "shall default to any supported language" (@none preferred).
573 88 : fn select_lang(lm: &Map<String, Value>, lang: &str) -> Option<(String, Value)> {
574 88 : let mut ranges: Vec<(f64, usize, &str)> = lang
575 88 : .split(',')
576 88 : .enumerate()
577 100 : .filter_map(|(i, part)| {
578 100 : let mut it = part.trim().split(';');
579 100 : let tag = it.next()?.trim();
580 100 : if tag.is_empty() {
581 0 : return None;
582 100 : }
583 100 : let q = it
584 100 : .find_map(|p| {
585 20 : p.trim()
586 20 : .strip_prefix("q=")
587 20 : .and_then(|v| v.parse::<f64>().ok())
588 20 : })
589 100 : .unwrap_or(1.0);
590 100 : Some((q, i, tag))
591 100 : })
592 88 : .collect();
593 88 : ranges.sort_by(|a, b| {
594 12 : b.0.partial_cmp(&a.0)
595 12 : .unwrap_or(std::cmp::Ordering::Equal)
596 12 : .then(a.1.cmp(&b.1))
597 12 : });
598 160 : let ci = |k: &str, want: &str| k.eq_ignore_ascii_case(want);
599 92 : for (q, _, want) in &ranges {
600 : // q=0 = "not acceptable" (RFC 3282); "*" = any → the fallback below
601 92 : if *q <= 0.0 || *want == "*" {
602 4 : continue;
603 88 : }
604 148 : if let Some((k, v)) = lm.iter().find(|(k, _)| ci(k, want)) {
605 60 : return Some((k.clone(), v.clone()));
606 28 : }
607 48 : if let Some((k, v)) = lm.iter().find(|(k, _)| {
608 48 : k.len() > want.len()
609 4 : && k.as_bytes().get(want.len()) == Some(&b'-')
610 4 : && ci(&k[..want.len()], want)
611 48 : }) {
612 4 : return Some((k.clone(), v.clone()));
613 24 : }
614 24 : let mut w = *want;
615 24 : while let Some(cut) = w.rfind('-') {
616 4 : w = &w[..cut];
617 8 : if let Some((k, v)) = lm.iter().find(|(k, _)| ci(k, w)) {
618 4 : return Some((k.clone(), v.clone()));
619 0 : }
620 : }
621 : }
622 : // any: prefer @none, then first
623 20 : if let Some(v) = lm.get("@none") {
624 0 : return Some(("@none".to_owned(), v.clone()));
625 20 : }
626 20 : lm.iter().next().map(|(k, v)| (k.clone(), v.clone()))
627 88 : }
628 :
629 90 : fn is_reserved_member(k: &str) -> bool {
630 18 : matches!(
631 90 : k,
632 90 : "type"
633 90 : | "value"
634 84 : | "object"
635 84 : | "objectType"
636 84 : | "datasetId"
637 84 : | "observedAt"
638 84 : | "unitCode"
639 84 : | "lang"
640 84 : | "languageMap"
641 84 : | "vocab"
642 84 : | "json"
643 84 : | "valueList"
644 80 : | "objectList"
645 76 : | "createdAt"
646 76 : | "modifiedAt"
647 76 : | "deletedAt"
648 76 : | "instanceId"
649 76 : | "previousValue"
650 72 : | "previousObject"
651 72 : | "previousLanguageMap"
652 72 : | "previousJson"
653 72 : | "previousVocab"
654 : )
655 90 : }
656 :
657 : /// 4.5.4: the simplified (keyValues) value of one instance — bare value for
658 : /// Property/GeoProperty, bare URI(s) for a Relationship, bare ordered arrays
659 : /// for ListProperty/ListRelationship, but the single-key wrapper objects
660 : /// {"languageMap": …} / {"json": …} / {"vocab": …} for the Language, Json
661 : /// and Vocab subtypes (Examples 4–6).
662 60 : fn simplified_value(obj: &Map<String, Value>) -> Value {
663 132 : for k in ["value", "object", "valueList", "objectList"] {
664 132 : if let Some(v) = obj.get(k) {
665 44 : return v.clone();
666 88 : }
667 : }
668 28 : for k in ["languageMap", "json", "vocab"] {
669 28 : if let Some(v) = obj.get(k) {
670 16 : return serde_json::json!({ k: v.clone() });
671 12 : }
672 : }
673 0 : Value::Object(obj.clone())
674 60 : }
675 :
676 : /// Compaction for a shaped doc under a representation: keyValues docs get
677 : /// shallow key renaming only (values are already plain JSON).
678 13916 : pub fn compact_for(
679 13916 : repr: &crate::repr::Repr,
680 13916 : shaped: &Value,
681 13916 : ctx: &antares_jsonld::Context,
682 13916 : ) -> Value {
683 13916 : if repr.key_values {
684 4 : compact_entity_shallow(shaped, ctx)
685 : } else {
686 13912 : compact_entity(shaped, ctx)
687 : }
688 13916 : }
689 :
690 : /// The child representation for a linked entity under `key` (4.21 nested
691 : /// projections apply to the joined entity, not the relationship itself).
692 5692 : fn joined_repr(parent: &crate::repr::Repr, key_compact: &str, key_iri: &str) -> crate::repr::Repr {
693 5692 : let mut r = crate::repr::Repr {
694 5692 : sys_attrs: parent.sys_attrs,
695 5692 : key_values: parent.key_values,
696 5692 : concise: parent.concise,
697 5692 : lang: parent.lang.clone(),
698 5692 : // ADR-0020: the narrowing is not a per-level projection — it
699 5692 : // travels to every document the answer carries
700 5692 : policy_pick: parent.policy_pick.clone(),
701 5692 : policy_omit: parent.policy_omit.clone(),
702 5692 : ..Default::default()
703 5692 : };
704 5692 : if let Some(pick) = &parent.pick {
705 16 : if let Some(n) = pick
706 16 : .iter()
707 54 : .find(|n| n.raw == key_compact || n.iri == key_iri)
708 16 : {
709 16 : r.pick = n.children.clone();
710 16 : }
711 5676 : }
712 5692 : if let Some(omit) = &parent.omit {
713 4 : if let Some(n) = omit
714 4 : .iter()
715 4 : .find(|n| (n.raw == key_compact || n.iri == key_iri) && n.children.is_some())
716 2 : {
717 2 : r.omit = n.children.clone();
718 2 : }
719 5688 : }
720 5692 : r
721 5692 : }
722 :
723 : /// 4.5.23.1: "When retrieving Linked Entities, it is necessary to limit
724 : /// retrieval to avoid cascades of an excessive length, duplicates or loops."
725 : /// joinLevel bounds the DEPTH of the walk; this bounds its WIDTH — the total
726 : /// number of Linked Entity reads a single request may buy, so that a densely
727 : /// linked graph cannot turn one retrieval into an unbounded store scan.
728 : pub(crate) const MAX_JOIN_LOOKUPS: usize = 1_000;
729 :
730 : /// State of one Linked Entity Retrieval walk (4.5.23.1): the entity ids
731 : /// already resolved — a loop or a duplicate is never walked a second time —
732 : /// and the remaining lookup budget. `complete` goes false as soon as the walk
733 : /// left something out, which the caller reports as an NGSILD-Warning.
734 : struct JoinWalk {
735 : seen: std::collections::BTreeSet<String>,
736 : budget: usize,
737 : complete: bool,
738 : }
739 :
740 : impl JoinWalk {
741 : /// The Linking Entity is already part of the response, so it counts as
742 : /// resolved before the walk starts — and so does every id the client
743 : /// passed in `containedBy`. `budget` is what is LEFT of the request's
744 : /// allowance: a page walks one entity at a time and each walk hands the
745 : /// remainder to the next, so the ceiling bounds the request rather than
746 : /// each of its entities.
747 48 : fn rooted(root: Option<&str>, contained_by: &[String], budget: usize) -> Self {
748 48 : let mut seen: std::collections::BTreeSet<String> = contained_by.iter().cloned().collect();
749 48 : if let Some(id) = root {
750 48 : seen.insert(id.to_owned());
751 48 : }
752 48 : JoinWalk {
753 48 : seen,
754 48 : budget,
755 48 : complete: true,
756 48 : }
757 48 : }
758 : }
759 :
760 : /// Linked Entity Retrieval, inline form (4.5.23.2): embed each relationship
761 : /// target under an "entity" member (normalized) or replace the object URI by
762 : /// the linked entity representation (simplified). Operates on COMPACTED docs.
763 : /// Returns false when 4.5.23.1 truncated the walk (loop, duplicate, budget).
764 12 : pub async fn inline_join(
765 12 : st: &AppState,
766 12 : tenant: &TenantId,
767 12 : ctx: &antares_jsonld::Context,
768 12 : repr: &crate::repr::Repr,
769 12 : compacted: &mut Value,
770 12 : level: usize,
771 12 : ) -> bool {
772 12 : inline_join_beyond(st, tenant, ctx, repr, compacted, level, &[], &mut {
773 12 : MAX_JOIN_LOOKUPS
774 12 : })
775 12 : .await
776 12 : }
777 :
778 : /// Same, continuing an Entity Graph the client is already holding: the
779 : /// `containedBy` ids count as encountered (Table 6.4.3.2-1).
780 : #[allow(clippy::too_many_arguments)] // one param per piece of the traversal's state
781 28 : pub async fn inline_join_beyond(
782 28 : st: &AppState,
783 28 : tenant: &TenantId,
784 28 : ctx: &antares_jsonld::Context,
785 28 : repr: &crate::repr::Repr,
786 28 : compacted: &mut Value,
787 28 : level: usize,
788 28 : contained_by: &[String],
789 28 : budget: &mut usize,
790 28 : ) -> bool {
791 28 : let mut walk = JoinWalk::rooted(
792 28 : compacted.get("id").and_then(Value::as_str),
793 28 : contained_by,
794 28 : *budget,
795 : );
796 28 : inline_join_walk(st, tenant, ctx, repr, compacted, level, &mut walk).await;
797 28 : *budget = walk.budget;
798 28 : walk.complete
799 28 : }
800 :
801 36 : async fn inline_join_walk(
802 36 : st: &AppState,
803 36 : tenant: &TenantId,
804 36 : ctx: &antares_jsonld::Context,
805 36 : repr: &crate::repr::Repr,
806 36 : compacted: &mut Value,
807 36 : level: usize,
808 36 : walk: &mut JoinWalk,
809 36 : ) {
810 36 : let Some(obj) = compacted.as_object_mut() else {
811 0 : return;
812 : };
813 36 : let metas = ["id", "type", "scope", "createdAt", "modifiedAt", "@context"];
814 3734 : for (k, v) in obj.iter_mut() {
815 3734 : if metas.contains(&k.as_str()) {
816 72 : continue;
817 3662 : }
818 3662 : let child = joined_repr(repr, k, &ctx.expand_key(k));
819 : // boxed: an async fn cannot recurse inline
820 3662 : Box::pin(inline_join_value(
821 3662 : st, tenant, ctx, repr, &child, v, level, walk,
822 3662 : ))
823 3662 : .await;
824 : }
825 36 : }
826 :
827 8048 : async fn lookup_joined(
828 8048 : st: &AppState,
829 8048 : tenant: &TenantId,
830 8048 : ctx: &antares_jsonld::Context,
831 8048 : child: &crate::repr::Repr,
832 8048 : id: &str,
833 8048 : level: usize,
834 8048 : walk: &mut JoinWalk,
835 8048 : ) -> Option<Value> {
836 8048 : if walk.budget == 0 {
837 800 : walk.complete = false;
838 800 : return None;
839 7248 : }
840 7248 : walk.budget -= 1;
841 7248 : let target = st
842 7248 : .store
843 7248 : .get(tenant, Kind::Entity, id)
844 7248 : .await
845 7248 : .ok()
846 7248 : .flatten()?;
847 7248 : let shaped = apply(&target, child);
848 7248 : let mut c = compact_for(child, &shaped, ctx);
849 7248 : if level > 1 {
850 36 : if walk.seen.insert(id.to_owned()) {
851 8 : inline_join_walk(st, tenant, ctx, child, &mut c, level - 1, walk).await;
852 28 : } else {
853 28 : // 4.5.23.1: an already-resolved target is a loop or a duplicate —
854 28 : // it is still embedded, but its own links are not walked again.
855 28 : walk.complete = false;
856 28 : }
857 7212 : }
858 7248 : Some(c)
859 8048 : }
860 :
861 : #[allow(clippy::too_many_arguments)] // one param per piece of the traversal's state
862 3662 : async fn inline_join_value(
863 3662 : st: &AppState,
864 3662 : tenant: &TenantId,
865 3662 : ctx: &antares_jsonld::Context,
866 3662 : repr: &crate::repr::Repr,
867 3662 : child: &crate::repr::Repr,
868 3662 : v: &mut Value,
869 3662 : level: usize,
870 3662 : walk: &mut JoinWalk,
871 3662 : ) {
872 0 : match v {
873 0 : Value::Array(items) => {
874 0 : for i in items {
875 : // boxed: an async fn cannot recurse inline
876 0 : Box::pin(inline_join_value(
877 0 : st, tenant, ctx, repr, child, i, level, walk,
878 0 : ))
879 0 : .await;
880 : }
881 : }
882 3662 : Value::Object(inst) => {
883 3662 : if repr.key_values {
884 0 : return;
885 3662 : }
886 : // 4.5.22.2: a ListRelationship's targets join under the
887 : // output-only "entityList" member (always an array). The
888 : // compacted objectList carries {"object": URI} entries.
889 3662 : if let Some(Value::Array(ol)) = inst.get("objectList") {
890 2 : let targets: Vec<String> = ol
891 2 : .iter()
892 4 : .filter_map(|e| match e {
893 0 : Value::String(id) => Some(id.clone()),
894 4 : Value::Object(o) => {
895 4 : o.get("object").and_then(Value::as_str).map(str::to_owned)
896 : }
897 0 : _ => None,
898 4 : })
899 2 : .collect();
900 2 : let mut joined: Vec<Value> = Vec::new();
901 4 : for id in &targets {
902 4 : if let Some(j) = lookup_joined(st, tenant, ctx, child, id, level, walk).await {
903 4 : joined.push(j);
904 4 : }
905 : }
906 2 : if !joined.is_empty() {
907 2 : inst.insert("entityList".into(), Value::Array(joined));
908 2 : }
909 2 : return;
910 3660 : }
911 3660 : let targets: Vec<String> = match inst.get("object") {
912 3644 : Some(Value::String(id)) => vec![id.clone()],
913 4 : Some(Value::Array(a)) => a
914 4 : .iter()
915 4 : .filter_map(Value::as_str)
916 4 : .map(str::to_owned)
917 4 : .collect(),
918 12 : _ => return,
919 : };
920 3648 : let mut joined: Vec<Value> = Vec::new();
921 8044 : for id in &targets {
922 8044 : if let Some(j) = lookup_joined(st, tenant, ctx, child, id, level, walk).await {
923 7244 : joined.push(j);
924 7244 : }
925 : }
926 3648 : if joined.is_empty() {
927 400 : return;
928 3248 : }
929 3248 : let e = if joined.len() == 1 {
930 3244 : joined.remove(0)
931 : } else {
932 4 : Value::Array(joined)
933 : };
934 3248 : inst.insert("entity".into(), e);
935 : }
936 : // simplified: relationship value is the object URI string
937 0 : Value::String(id) if repr.key_values => {
938 0 : if let Some(joined) = lookup_joined(st, tenant, ctx, child, id, level, walk).await {
939 0 : *v = joined;
940 0 : }
941 : }
942 0 : _ => {}
943 : }
944 3662 : }
945 :
946 : /// Linked Entity Retrieval, flattened form (4.5.23.3): collect targets with
947 : /// the child representation that applies to each. The Linking Entity is
948 : /// already in the flattened array, so 4.5.23.1 ("avoid ... duplicates or
949 : /// loops") keeps it out of `out` even when a Relationship points back at it.
950 : /// Returns false when the walk was truncated by the lookup budget.
951 4 : pub async fn collect_flat(
952 4 : st: &AppState,
953 4 : tenant: &TenantId,
954 4 : repr: &crate::repr::Repr,
955 4 : internal_doc: &Value,
956 4 : level: usize,
957 4 : out: &mut std::collections::BTreeMap<String, (Value, crate::repr::Repr)>,
958 4 : ) -> bool {
959 4 : collect_flat_beyond(st, tenant, repr, internal_doc, level, out, &[], &mut {
960 4 : MAX_JOIN_LOOKUPS
961 4 : })
962 4 : .await
963 4 : }
964 :
965 : /// Same, continuing an Entity Graph the client is already holding: the
966 : /// `containedBy` ids count as encountered (Table 6.4.3.2-1).
967 : #[allow(clippy::too_many_arguments)] // one param per piece of the traversal's state
968 20 : pub async fn collect_flat_beyond(
969 20 : st: &AppState,
970 20 : tenant: &TenantId,
971 20 : repr: &crate::repr::Repr,
972 20 : internal_doc: &Value,
973 20 : level: usize,
974 20 : out: &mut std::collections::BTreeMap<String, (Value, crate::repr::Repr)>,
975 20 : contained_by: &[String],
976 20 : budget: &mut usize,
977 20 : ) -> bool {
978 20 : let mut walk = JoinWalk::rooted(
979 20 : internal_doc.get("id").and_then(Value::as_str),
980 20 : contained_by,
981 20 : *budget,
982 : );
983 20 : walk.seen.extend(out.keys().cloned());
984 20 : collect_flat_walk(st, tenant, repr, internal_doc, level, out, &mut walk).await;
985 20 : *budget = walk.budget;
986 20 : walk.complete
987 20 : }
988 :
989 : #[allow(clippy::too_many_arguments)] // one param per piece of the traversal's state
990 28 : async fn collect_flat_walk(
991 28 : st: &AppState,
992 28 : tenant: &TenantId,
993 28 : repr: &crate::repr::Repr,
994 28 : internal_doc: &Value,
995 28 : level: usize,
996 28 : out: &mut std::collections::BTreeMap<String, (Value, crate::repr::Repr)>,
997 28 : walk: &mut JoinWalk,
998 28 : ) {
999 28 : let Some(obj) = internal_doc.as_object() else {
1000 0 : return;
1001 : };
1002 2138 : for (k, v) in obj {
1003 2138 : if is_meta(k) {
1004 106 : continue;
1005 2032 : }
1006 : // only traverse relationships that survive THIS doc's projection
1007 2032 : if let Some(pick) = &repr.pick {
1008 0 : if !pick.iter().any(|n| n.iri == *k || n.raw == *k) {
1009 0 : continue;
1010 0 : }
1011 2032 : }
1012 2032 : if let Some(omit) = &repr.omit {
1013 0 : if omit
1014 0 : .iter()
1015 0 : .any(|n| (n.iri == *k || n.raw == *k) && n.children.is_none())
1016 : {
1017 0 : continue;
1018 0 : }
1019 2032 : }
1020 : // a Relationship the subject may not see is not a road either
1021 2032 : if !policy_projected(repr.policy_pick.as_deref(), repr.policy_omit.as_deref(), k) {
1022 2 : continue;
1023 2030 : }
1024 2030 : let Some(instances) = v.as_array() else {
1025 0 : continue;
1026 : };
1027 2030 : let child = joined_repr(repr, k, k);
1028 2030 : for inst in instances {
1029 : // Relationship objects plus ListRelationship objectList targets
1030 : // (internal form stores bare URIs) — 4.5.23.3 appends both kinds
1031 : // of Linked Entities to the flattened array.
1032 2030 : let targets: Vec<&str> = match (inst.get("object"), inst.get("objectList")) {
1033 2026 : (Some(Value::String(id)), _) => vec![id.as_str()],
1034 0 : (Some(Value::Array(a)), _) => a.iter().filter_map(Value::as_str).collect(),
1035 2 : (None, Some(Value::Array(a))) => a.iter().filter_map(Value::as_str).collect(),
1036 2 : _ => continue,
1037 : };
1038 2030 : for id in targets {
1039 2030 : if walk.seen.contains(id) {
1040 10 : continue;
1041 2020 : }
1042 2020 : if walk.budget == 0 {
1043 2 : walk.complete = false;
1044 2 : return;
1045 2018 : }
1046 2018 : walk.budget -= 1;
1047 2018 : if let Some(target) = st.store.get(tenant, Kind::Entity, id).await.ok().flatten() {
1048 2018 : walk.seen.insert(id.to_owned());
1049 2018 : out.insert(id.to_owned(), (target.clone(), child.clone()));
1050 2018 : if level > 1 {
1051 : // boxed: an async fn cannot recurse inline
1052 8 : Box::pin(collect_flat_walk(
1053 8 : st,
1054 8 : tenant,
1055 8 : &child,
1056 8 : &target,
1057 8 : level - 1,
1058 8 : out,
1059 8 : walk,
1060 8 : ))
1061 8 : .await;
1062 2010 : }
1063 0 : }
1064 : }
1065 : }
1066 : }
1067 28 : }
1068 :
1069 : /// 4.5.16.2 GeoJSON Feature, members per Table 5.2.29-1 (5.2.29 Feature):
1070 : /// id = entity id (URI), fixed type "Feature", geometry = the selected
1071 : /// GeoProperty's value or null (4.5.16.1: geometryProperty parameter,
1072 : /// default "location"), properties = the 5.2.31 FeatureProperties (entity
1073 : /// type + attributes). The @context member is added by respond() (6.3.6).
1074 38 : pub fn to_geojson_feature(entity: Value, geometry_property: Option<&String>) -> Value {
1075 38 : let geom_term = geometry_property
1076 38 : .cloned()
1077 38 : .unwrap_or_else(|| "location".into());
1078 38 : let geometry = entity
1079 38 : .get(&geom_term)
1080 38 : .map(geo_value_of)
1081 38 : .unwrap_or(Value::Null);
1082 38 : let id = entity.get("id").cloned().unwrap_or(Value::Null);
1083 38 : let mut props = entity.as_object().cloned().unwrap_or_default();
1084 38 : props.remove("id");
1085 38 : let mut feature = Map::new();
1086 38 : feature.insert("id".into(), id);
1087 38 : feature.insert("type".into(), Value::String("Feature".into()));
1088 38 : feature.insert("geometry".into(), geometry);
1089 38 : feature.insert("properties".into(), Value::Object(props));
1090 38 : Value::Object(feature)
1091 38 : }
1092 :
1093 : /// 4.5.16.3 GeoJSON FeatureCollection, members per Table 5.2.30-1 (5.2.30
1094 : /// FeatureCollection): fixed type "FeatureCollection" + features array of
1095 : /// 4.5.16.2 Feature objects — empty array when no matches, no per-Feature
1096 : /// @context; the top-level @context is added by respond() (6.3.6).
1097 24 : pub fn to_geojson_collection(entities: Vec<Value>, geometry_property: Option<&String>) -> Value {
1098 24 : let features: Vec<Value> = entities
1099 24 : .into_iter()
1100 24 : .map(|e| to_geojson_feature(e, geometry_property))
1101 24 : .collect();
1102 24 : serde_json::json!({"type": "FeatureCollection", "features": features})
1103 24 : }
1104 :
1105 : /// 4.5.16.1: with multiple instances the default one (no datasetId) is
1106 : /// selected unless a datasetId filter already narrowed the set to one; a
1107 : /// missing GeoProperty or a value that "does not hold a valid GeoJSON
1108 : /// geometry object" yields null — "which is syntactically valid GeoJSON".
1109 30 : fn geo_value_of(attr: &Value) -> Value {
1110 30 : let inst = match attr {
1111 16 : Value::Array(a) => match a.iter().find(|i| i.get("datasetId").is_none()) {
1112 8 : Some(default) => default.clone(),
1113 0 : None if a.len() == 1 => a[0].clone(),
1114 0 : None => return Value::Null,
1115 : },
1116 22 : other => other.clone(),
1117 : };
1118 30 : let v = inst.get("value").cloned().unwrap_or(inst);
1119 : // 4.5.17.1: in the simplified representation a multi-instance GeoProperty
1120 : // is the {"dataset": {…}} map — the default ("@none") instance is the
1121 : // 4.5.16.1 selection.
1122 30 : let v = match v.as_object() {
1123 26 : Some(o) if o.len() == 1 && o.contains_key("dataset") => {
1124 4 : o["dataset"].get("@none").cloned().unwrap_or(Value::Null)
1125 : }
1126 26 : _ => v,
1127 : };
1128 30 : match antares_jsonld::expand::validate_geojson("geometry", &v) {
1129 26 : Ok(()) => v,
1130 4 : Err(_) => Value::Null,
1131 : }
1132 30 : }
1133 :
1134 : #[cfg(test)]
1135 : mod tests {
1136 : use super::*;
1137 : use serde_json::json;
1138 :
1139 : /// 4.5.4 Examples 1–16: the simplified value of one instance per
1140 : /// attribute type — bare for Property/GeoProperty/Relationship/List*,
1141 : /// wrapped single-key objects for Language/Json/Vocab subtypes.
1142 : #[test]
1143 4 : fn simplified_values_per_attribute_type() {
1144 28 : let v = |j: Value| simplified_value(j.as_object().unwrap());
1145 4 : assert_eq!(v(json!({"type": "Property", "value": 5})), json!(5));
1146 4 : assert_eq!(
1147 4 : v(json!({"type": "Relationship", "object": "urn:a"})),
1148 4 : json!("urn:a")
1149 : );
1150 4 : assert_eq!(
1151 4 : v(json!({"type": "ListProperty", "valueList": [1, 2]})),
1152 4 : json!([1, 2])
1153 : );
1154 4 : assert_eq!(
1155 4 : v(json!({"type": "ListRelationship", "objectList": ["urn:a"]})),
1156 4 : json!(["urn:a"])
1157 : );
1158 4 : assert_eq!(
1159 4 : v(json!({"type": "LanguageProperty", "languageMap": {"en": "hi"}})),
1160 4 : json!({"languageMap": {"en": "hi"}})
1161 : );
1162 4 : assert_eq!(
1163 4 : v(json!({"type": "JsonProperty", "json": {"k": 1}})),
1164 4 : json!({"json": {"k": 1}})
1165 : );
1166 4 : assert_eq!(
1167 4 : v(json!({"type": "VocabProperty", "vocab": "V"})),
1168 4 : json!({"vocab": "V"})
1169 : );
1170 4 : }
1171 : }
1172 :
1173 : #[cfg(test)]
1174 : mod clause_4_15 {
1175 : use super::*;
1176 : use serde_json::json;
1177 :
1178 36 : fn lm(pairs: &[(&str, &str)]) -> Map<String, Value> {
1179 36 : pairs
1180 36 : .iter()
1181 60 : .map(|(k, v)| ((*k).to_owned(), json!(*v)))
1182 36 : .collect()
1183 36 : }
1184 :
1185 : /// 4.15 EXAMPLE 4: quality value ranking — entries are ordered by their
1186 : /// q weight (default 1), not by list position.
1187 : #[test]
1188 4 : fn q_values_rank_the_priority_list() {
1189 4 : let m = lm(&[("en", "red"), ("fr", "rouge")]);
1190 4 : let (l, v) = select_lang(&m, "en;q=0.2,fr;q=0.9").expect("pick");
1191 4 : assert_eq!((l.as_str(), &v), ("fr", &json!("rouge")), "fr outranks en");
1192 : // default q=1: plain fr-CH beats fr;q=0.9
1193 4 : let m = lm(&[("fr-CH", "rouge suisse"), ("fr", "rouge")]);
1194 4 : let (l, _) = select_lang(&m, "fr-CH,fr;q=0.9").expect("pick");
1195 4 : assert_eq!(l, "fr-CH");
1196 : // wildcard with low q still yields a fallback when nothing else fits
1197 4 : let m = lm(&[("de", "rot")]);
1198 4 : let (l, _) = select_lang(&m, "fr;q=0.9,*;q=0.5").expect("pick");
1199 4 : assert_eq!(l, "de");
1200 4 : }
1201 :
1202 : /// RFC 5646 (via 4.15): language tags compare case-insensitively.
1203 : #[test]
1204 4 : fn langtags_compare_case_insensitively() {
1205 4 : let m = lm(&[("en-US", "color")]);
1206 4 : let (l, v) = select_lang(&m, "en-us").expect("pick");
1207 4 : assert_eq!((l.as_str(), &v), ("en-US", &json!("color")));
1208 4 : let m = lm(&[("fr", "rouge"), ("de", "rot")]);
1209 4 : let (l, _) = select_lang(&m, "FR").expect("pick");
1210 4 : assert_eq!(l, "fr");
1211 4 : }
1212 :
1213 : /// RFC 5646 lookup (via 4.15): a shorter range matches a longer tag
1214 : /// (lang=fr picks fr-CH) and a longer range truncates onto a shorter tag
1215 : /// (lang=fr-CH picks fr) — the `lang` subproperty reports the ACTUAL tag.
1216 : #[test]
1217 4 : fn prefix_and_truncation_fallbacks() {
1218 : // decoy `de` sorts first — a naive any-fallback would pick it
1219 4 : let m = lm(&[("de", "rot"), ("fr-CH", "rouge suisse")]);
1220 4 : let (l, _) = select_lang(&m, "fr").expect("pick");
1221 4 : assert_eq!(l, "fr-CH", "range fr matches tag fr-CH, not the decoy");
1222 4 : let m = lm(&[("de", "rot"), ("fr", "rouge")]);
1223 4 : let (l, _) = select_lang(&m, "fr-CH").expect("pick");
1224 4 : assert_eq!(l, "fr", "range fr-CH truncates onto tag fr, not the decoy");
1225 : // an exact match still beats a prefix match at the same rank
1226 4 : let m = lm(&[("fr-CH", "suisse"), ("fr", "rouge")]);
1227 4 : let (l, _) = select_lang(&m, "fr").expect("pick");
1228 4 : assert_eq!(l, "fr");
1229 4 : }
1230 :
1231 : /// 4.15: "If the Context Broker cannot serve any matching language, it
1232 : /// shall default to any supported language" — and the augmented `lang`
1233 : /// subproperty carries the actually returned one.
1234 : #[test]
1235 4 : fn no_match_falls_back_to_any_supported_language() {
1236 4 : let m = lm(&[("de", "rot")]);
1237 4 : let (l, v) = select_lang(&m, "pt").expect("fallback");
1238 4 : assert_eq!((l.as_str(), &v), ("de", &json!("rot")));
1239 : // the transform augments with the actual language and converts the
1240 : // LanguageProperty to a Property — languageMap must NOT survive
1241 4 : let inst = json!({"type": "LanguageProperty",
1242 4 : "languageMap": {"en": "red", "fr": "rouge"}});
1243 4 : let r = Repr {
1244 4 : lang: Some("fr".into()),
1245 4 : ..Repr::default()
1246 4 : };
1247 4 : let out = transform_instance(&inst, &r);
1248 4 : assert_eq!(out["type"], "Property");
1249 4 : assert_eq!(out["value"], "rouge");
1250 4 : assert_eq!(out["lang"], "fr");
1251 4 : assert!(
1252 4 : out.get("languageMap").is_none(),
1253 : "languageMap must not remain after conversion"
1254 : );
1255 4 : }
1256 : }
1257 :
1258 : #[cfg(test)]
1259 : mod clause_4_21 {
1260 : use super::*;
1261 : use antares_jsonld::Loader;
1262 :
1263 : /// 4.21: "either a comma or a pipe character can be used as alternative
1264 : /// representations of the or operator" — including inside a nested
1265 : /// LinkedEntityTerm (EXAMPLE 3).
1266 : #[test]
1267 4 : fn pipe_and_comma_are_both_or_operators() {
1268 4 : let ctx = Loader::new().core();
1269 4 : let comma = parse_projection("temperature,humidity", &ctx).expect("comma");
1270 4 : let pipe = parse_projection("temperature|humidity", &ctx).expect("pipe");
1271 4 : assert_eq!(comma.len(), 2);
1272 4 : assert_eq!(pipe.len(), 2);
1273 4 : assert_eq!(comma[0].raw, pipe[0].raw);
1274 4 : assert_eq!(comma[1].raw, pipe[1].raw);
1275 4 : let nested = parse_projection("observation{temperature|humidity}", &ctx).expect("nested");
1276 4 : assert_eq!(nested.len(), 1);
1277 4 : let kids = nested[0].children.as_ref().expect("children");
1278 4 : assert_eq!(kids.len(), 2, "pipe splits inside the braces too");
1279 4 : }
1280 :
1281 : /// 4.21 grammar: an empty member or unbalanced braces are violations.
1282 : #[test]
1283 4 : fn grammar_rejections_hold_for_both_spellings() {
1284 4 : let ctx = Loader::new().core();
1285 20 : for bad in ["a||b", "a|,b", "|a", "a|", "a{b|}"] {
1286 20 : assert!(
1287 20 : parse_projection(bad, &ctx).is_err(),
1288 : "{bad:?} must be rejected"
1289 : );
1290 : }
1291 4 : }
1292 :
1293 : /// Each `{…}` level of a projection is one Linked Entity hop (5.7.1.4),
1294 : /// so a selection deeper than the joinLevel ceiling can never be
1295 : /// satisfied. The depth is bounded BEFORE the recursive descent parses
1296 : /// it: pick= arrives as a plain string inside a query body, where the
1297 : /// JSON nesting wall never sees its braces.
1298 : #[test]
1299 4 : fn projection_nesting_is_bounded_before_the_parser_recurses() {
1300 4 : let ctx = Loader::new().core();
1301 4 : let cap = crate::bounds::MAX_JOIN_LEVEL;
1302 12 : let nested = |n: usize| "a{".repeat(n) + "b" + &"}".repeat(n);
1303 : // the body path can carry a projection far past any stack budget
1304 4 : assert!(
1305 4 : parse_projection(&nested(200_000), &ctx).is_err(),
1306 : "a body-sized projection must be rejected, not recursed into"
1307 : );
1308 4 : assert!(parse_projection(&nested(cap), &ctx).is_ok(), "at the cap");
1309 4 : assert!(
1310 4 : parse_projection(&nested(cap + 1), &ctx).is_err(),
1311 : "one level over the cap must be rejected"
1312 : );
1313 4 : }
1314 :
1315 : /// 5.7.1.4: proj_depth reports the hops a projection implies, so the
1316 : /// joinLevel comparison upstream is made against the deepest branch.
1317 : #[test]
1318 4 : fn projection_depth_counts_the_deepest_branch() {
1319 4 : let ctx = Loader::new().core();
1320 12 : let d = |s: &str| proj_depth(&parse_projection(s, &ctx).expect("parse"));
1321 4 : assert_eq!(d("a,b"), 0, "a flat selection implies no hop");
1322 4 : assert_eq!(d("a{b}"), 1);
1323 4 : assert_eq!(d("a,b{c{d}}"), 2, "the deepest branch wins");
1324 4 : }
1325 : }
1326 :
1327 : #[cfg(test)]
1328 : mod clause_6_3_7 {
1329 : use super::*;
1330 : use antares_jsonld::Loader;
1331 : use serde_json::json;
1332 :
1333 88 : fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1334 88 : pairs
1335 88 : .iter()
1336 116 : .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1337 88 : .collect()
1338 88 : }
1339 :
1340 : /// 6.3.7: an unknown options or format value is not silently ignored.
1341 : #[test]
1342 4 : fn unknown_options_and_format_values_are_rejected() {
1343 4 : let ctx = Loader::new().core();
1344 20 : for p in [
1345 4 : params(&[("options", "sysattrs")]),
1346 4 : params(&[("options", "keyValues,bogus")]),
1347 4 : params(&[("options", "")]),
1348 4 : params(&[("format", "verbose")]),
1349 4 : params(&[("format", "KeyValues")]),
1350 4 : ] {
1351 20 : let e = parse_repr(&p, &ctx).expect_err("must be rejected");
1352 20 : assert!(
1353 20 : matches!(e, NgsiError::InvalidRequest(_)),
1354 : "unsupported representation value is InvalidRequest, got {e:?}"
1355 : );
1356 : }
1357 4 : }
1358 :
1359 : /// 6.3.7: format wins over options when the two disagree, and keyValues
1360 : /// is the older spelling of simplified.
1361 : #[test]
1362 4 : fn format_wins_over_options_on_conflict() {
1363 4 : let ctx = Loader::new().core();
1364 4 : let r = parse_repr(
1365 4 : ¶ms(&[("options", "concise"), ("format", "simplified")]),
1366 4 : &ctx,
1367 : )
1368 4 : .expect("parse");
1369 4 : assert!(r.key_values, "format=simplified wins");
1370 4 : assert!(!r.concise, "options=concise must NOT survive the conflict");
1371 4 : let r = parse_repr(
1372 4 : ¶ms(&[("options", "keyValues"), ("format", "normalized")]),
1373 4 : &ctx,
1374 : )
1375 4 : .expect("parse");
1376 4 : assert!(!r.key_values && !r.concise, "normalized is neither");
1377 4 : let r = parse_repr(¶ms(&[("options", "sysAttrs,keyValues")]), &ctx).expect("parse");
1378 4 : assert!(r.sys_attrs && r.key_values);
1379 4 : }
1380 :
1381 : /// 4.21: pick, omit and attrs are mutually exclusive — any pair is a 400,
1382 : /// each one alone is fine.
1383 : #[test]
1384 4 : fn pick_omit_and_attrs_cannot_be_combined() {
1385 4 : let ctx = Loader::new().core();
1386 16 : for p in [
1387 4 : params(&[("pick", "a"), ("omit", "b")]),
1388 4 : params(&[("pick", "a"), ("attrs", "b")]),
1389 4 : params(&[("omit", "a"), ("attrs", "b")]),
1390 4 : params(&[("pick", "a"), ("omit", "b"), ("attrs", "c")]),
1391 4 : ] {
1392 16 : let e = parse_repr(&p, &ctx).expect_err("must be rejected");
1393 16 : assert!(matches!(e, NgsiError::BadRequestData(_)), "got {e:?}");
1394 : }
1395 4 : assert!(parse_repr(¶ms(&[("pick", "a")]), &ctx).is_ok());
1396 4 : assert!(parse_repr(¶ms(&[("attrs", "a")]), &ctx).is_ok());
1397 4 : }
1398 :
1399 : /// attrs= selects ATTRIBUTES: an entity meta member or @context is not an
1400 : /// attribute name, and an empty member is a grammar violation.
1401 : #[test]
1402 4 : fn attrs_rejects_entity_members_and_empty_names() {
1403 4 : let ctx = Loader::new().core();
1404 28 : for bad in ["id", "type", "scope", "createdAt", "@context", "a,,b", ""] {
1405 28 : assert!(
1406 28 : parse_repr(¶ms(&[("attrs", bad)]), &ctx).is_err(),
1407 : "attrs={bad:?} must be rejected"
1408 : );
1409 : }
1410 4 : let r = parse_repr(¶ms(&[("attrs", "temperature, humidity")]), &ctx).expect("parse");
1411 4 : let list = r.attrs.expect("attrs");
1412 4 : assert_eq!(list.len(), 2);
1413 8 : assert!(list.iter().all(|a| a.contains("://")), "names are expanded");
1414 4 : }
1415 :
1416 36 : fn entity() -> Value {
1417 36 : json!({
1418 36 : "id": "urn:ngsi-ld:E:1",
1419 36 : "type": "T",
1420 36 : "createdAt": "2026-01-01T00:00:00Z",
1421 36 : "modifiedAt": "2026-01-02T00:00:00Z",
1422 36 : "https://example.org/temperature": [
1423 36 : {"type": "Property", "value": 21,
1424 36 : "createdAt": "2026-01-01T00:00:00Z",
1425 36 : "https://example.org/accuracy": [{"type": "Property", "value": 0.5}]},
1426 36 : {"type": "Property", "value": 9, "datasetId": "urn:ds:2"}
1427 : ]
1428 : })
1429 36 : }
1430 :
1431 : /// 6.3.11 Table 6.3.11-1: createdAt/modifiedAt are system attributes —
1432 : /// they must be absent from the default representation, on the entity AND
1433 : /// on every attribute instance, and present with options=sysAttrs.
1434 : #[test]
1435 4 : fn system_attributes_stay_hidden_without_sysattrs() {
1436 4 : let plain = apply(&entity(), &Repr::default());
1437 4 : assert!(plain.get("createdAt").is_none(), "entity createdAt leaked");
1438 4 : assert!(
1439 4 : plain.get("modifiedAt").is_none(),
1440 : "entity modifiedAt leaked"
1441 : );
1442 4 : assert!(
1443 4 : plain["https://example.org/temperature"][0]
1444 4 : .get("createdAt")
1445 4 : .is_none(),
1446 : "instance createdAt leaked"
1447 : );
1448 4 : assert_eq!(plain["id"], "urn:ngsi-ld:E:1");
1449 4 : let sys = apply(
1450 4 : &entity(),
1451 4 : &Repr {
1452 4 : sys_attrs: true,
1453 4 : ..Repr::default()
1454 4 : },
1455 : );
1456 4 : assert_eq!(sys["createdAt"], "2026-01-01T00:00:00Z");
1457 4 : assert_eq!(
1458 4 : sys["https://example.org/temperature"][0]["createdAt"],
1459 : "2026-01-01T00:00:00Z"
1460 : );
1461 4 : }
1462 :
1463 : /// 4.5.4 concise: the type member is dropped and an instance left with a
1464 : /// lone value collapses to that bare value — sub-attributes included.
1465 : #[test]
1466 4 : fn concise_drops_type_and_collapses_bare_values() {
1467 4 : let out = apply(
1468 4 : &entity(),
1469 4 : &Repr {
1470 4 : concise: true,
1471 4 : ..Repr::default()
1472 4 : },
1473 : );
1474 4 : let inst = &out["https://example.org/temperature"][0];
1475 4 : assert!(inst.get("type").is_none(), "concise keeps no type member");
1476 4 : assert_eq!(
1477 4 : inst["https://example.org/accuracy"],
1478 4 : json!([0.5]),
1479 : "a value-only sub-attribute collapses to the bare value"
1480 : );
1481 : // an instance carrying more than value keeps the object form
1482 4 : let d = apply(
1483 4 : &json!({"https://example.org/a": [{"type": "Property", "value": 1, "unitCode": "CEL"}]}),
1484 4 : &Repr {
1485 4 : concise: true,
1486 4 : ..Repr::default()
1487 4 : },
1488 : );
1489 4 : assert_eq!(d["https://example.org/a"][0]["unitCode"], "CEL");
1490 4 : assert_eq!(d["https://example.org/a"][0]["value"], 1);
1491 4 : }
1492 :
1493 : /// 4.5.4: with several instances the simplified form is a dataset map
1494 : /// keyed by datasetId, "@none" standing for the default instance — a bare
1495 : /// array of values would lose which instance is which. The map carries one
1496 : /// pair for each datasetId, so a dropped instance is a dropped datasetId.
1497 : #[test]
1498 4 : fn simplified_multi_instance_uses_the_dataset_map() {
1499 4 : let out = apply(
1500 4 : &entity(),
1501 4 : &Repr {
1502 4 : key_values: true,
1503 4 : ..Repr::default()
1504 4 : },
1505 : );
1506 4 : let t = &out["https://example.org/temperature"];
1507 4 : assert!(t.get("dataset").is_some(), "multi-instance needs the map");
1508 4 : assert_eq!(t["dataset"]["@none"], 21);
1509 4 : assert_eq!(t["dataset"]["urn:ds:2"], 9);
1510 4 : assert!(t.as_array().is_none(), "not a bare array");
1511 : // one instance stays a bare value
1512 4 : let one = apply(
1513 4 : &json!({"https://example.org/a": [{"type": "Property", "value": 1}]}),
1514 4 : &Repr {
1515 4 : key_values: true,
1516 4 : ..Repr::default()
1517 4 : },
1518 : );
1519 4 : assert_eq!(one["https://example.org/a"], json!(1));
1520 : // 4.5.4 EXAMPLE 2 is three instances; the map is built by pairing
1521 : // instances with the rows they came from, so an instance lost on the
1522 : // way leaves a map one pair short rather than an error
1523 4 : let three = apply(
1524 4 : &json!({"https://example.org/name": [
1525 4 : {"type": "Property", "value": "David Robert Jones"},
1526 4 : {"type": "Property", "value": "David Bowie",
1527 4 : "datasetId": "urn:ngsi-ld:datasetId:001"},
1528 4 : {"type": "Property", "value": "Ziggy Stardust",
1529 4 : "datasetId": "urn:ngsi-ld:datasetId:002"}
1530 4 : ]}),
1531 4 : &Repr {
1532 4 : key_values: true,
1533 4 : ..Repr::default()
1534 4 : },
1535 : );
1536 4 : let ds = three["https://example.org/name"]["dataset"]
1537 4 : .as_object()
1538 4 : .expect("dataset map");
1539 4 : assert_eq!(ds.len(), 3, "one pair for each datasetId");
1540 4 : assert_eq!(ds["@none"], "David Robert Jones");
1541 4 : assert_eq!(ds["urn:ngsi-ld:datasetId:001"], "David Bowie");
1542 4 : assert_eq!(ds["urn:ngsi-ld:datasetId:002"], "Ziggy Stardust");
1543 4 : }
1544 :
1545 : /// datasetId= selects instances; "@none" selects the default one. An
1546 : /// attribute left with no surviving instance is absent, not empty.
1547 : #[test]
1548 4 : fn dataset_id_filter_selects_instances_and_drops_empty_attributes() {
1549 12 : let sel = |ids: &[&str]| {
1550 12 : apply(
1551 12 : &entity(),
1552 : &Repr {
1553 12 : dataset_id: Some(ids.iter().map(|s| (*s).to_owned()).collect()),
1554 12 : ..Repr::default()
1555 : },
1556 : )
1557 12 : };
1558 4 : let out = sel(&["urn:ds:2"]);
1559 4 : let insts = out["https://example.org/temperature"]
1560 4 : .as_array()
1561 4 : .expect("array");
1562 4 : assert_eq!(insts.len(), 1);
1563 4 : assert_eq!(insts[0]["value"], 9, "the default instance must be gone");
1564 4 : let out = sel(&["@none"]);
1565 4 : let insts = out["https://example.org/temperature"]
1566 4 : .as_array()
1567 4 : .expect("array");
1568 4 : assert_eq!(insts.len(), 1);
1569 4 : assert_eq!(insts[0]["value"], 21);
1570 4 : let out = sel(&["urn:ds:absent"]);
1571 4 : assert!(
1572 4 : out.get("https://example.org/temperature").is_none(),
1573 : "an attribute with no matching instance is omitted entirely"
1574 : );
1575 4 : assert_eq!(out["id"], "urn:ngsi-ld:E:1", "entity members survive");
1576 4 : }
1577 :
1578 : /// The reserved members of an attribute instance carry values, not
1579 : /// sub-attributes: an array-valued `value` (or objectList, previousValue…)
1580 : /// must be passed through untouched, never walked as a list of instances.
1581 : #[test]
1582 4 : fn array_valued_reserved_members_are_not_sub_attributes() {
1583 4 : let doc = json!({"https://example.org/a": [{
1584 4 : "type": "ListProperty",
1585 4 : "valueList": [1, 2, 3],
1586 4 : "value": [{"type": "Property", "value": 7}],
1587 4 : "previousValue": [9],
1588 4 : "https://example.org/note": [{"type": "Property", "value": "sub"}]
1589 : }]});
1590 4 : let out = apply(&doc, &Repr::default());
1591 4 : let inst = &out["https://example.org/a"][0];
1592 4 : assert_eq!(inst["valueList"], json!([1, 2, 3]));
1593 4 : assert_eq!(
1594 4 : inst["value"],
1595 4 : json!([{"type": "Property", "value": 7}]),
1596 : "an array value is data, not an instance list to transform"
1597 : );
1598 4 : assert_eq!(inst["previousValue"], json!([9]));
1599 : // a genuine sub-attribute IS walked
1600 4 : assert_eq!(inst["https://example.org/note"][0]["value"], "sub");
1601 : // …and the walk applies the representation to it
1602 4 : let sys = apply(
1603 4 : &json!({"https://example.org/a": [{"type": "Property", "value": 1,
1604 4 : "https://example.org/note": [{"type": "Property", "value": "s",
1605 4 : "modifiedAt": "2026-01-01T00:00:00Z"}]}]}),
1606 4 : &Repr::default(),
1607 : );
1608 4 : assert!(
1609 4 : sys["https://example.org/a"][0]["https://example.org/note"][0]
1610 4 : .get("modifiedAt")
1611 4 : .is_none(),
1612 : "the sysAttrs gate reaches sub-attributes"
1613 : );
1614 4 : }
1615 :
1616 : /// 4.21: pick constrains core members too — an entity member not picked
1617 : /// does not survive; omit only drops the heads it names outright.
1618 : #[test]
1619 4 : fn pick_is_strict_over_core_members_and_omit_is_not() {
1620 4 : let ctx = Loader::new().core();
1621 4 : let picked = apply(
1622 4 : &entity(),
1623 4 : &Repr {
1624 4 : pick: Some(parse_projection("id", &ctx).expect("pick")),
1625 4 : ..Repr::default()
1626 4 : },
1627 : );
1628 4 : assert_eq!(picked["id"], "urn:ngsi-ld:E:1");
1629 4 : assert!(picked.get("type").is_none(), "type was not picked");
1630 4 : assert!(
1631 4 : picked.get("https://example.org/temperature").is_none(),
1632 : "an unpicked attribute must not survive"
1633 : );
1634 4 : let omitted = apply(
1635 4 : &entity(),
1636 4 : &Repr {
1637 4 : omit: Some(parse_projection("scope", &ctx).expect("omit")),
1638 4 : ..Repr::default()
1639 4 : },
1640 : );
1641 4 : assert_eq!(omitted["type"], "T", "omit leaves the rest of the entity");
1642 4 : }
1643 :
1644 : /// 4.5.16.1/4.5.16.2/4.5.16.3: geometry selection (default instance,
1645 : /// datasetId-narrowed single, invalid value -> null) and the
1646 : /// Feature/FeatureCollection shapes.
1647 : #[test]
1648 4 : fn geojson_feature_selection_and_shape() {
1649 : use super::{to_geojson_collection, to_geojson_feature};
1650 : use serde_json::Value;
1651 4 : let entity = json!({
1652 4 : "id": "urn:ngsi-ld:V:1", "type": "Vehicle",
1653 4 : "location": [
1654 4 : {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [9.0, 9.0]},
1655 4 : "datasetId": "urn:ngsi-ld:Dataset:gps"},
1656 4 : {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [1.0, 2.0]}}
1657 : ],
1658 4 : "speed": {"type": "Property", "value": 5}
1659 : });
1660 4 : let f = to_geojson_feature(entity.clone(), None);
1661 4 : assert_eq!(f["type"], "Feature");
1662 4 : assert_eq!(f["id"], "urn:ngsi-ld:V:1");
1663 : // default instance (no datasetId) wins over the first array element
1664 4 : assert_eq!(
1665 4 : f["geometry"],
1666 4 : json!({"type": "Point", "coordinates": [1.0, 2.0]})
1667 : );
1668 4 : assert_eq!(f["properties"]["type"], "Vehicle");
1669 4 : assert!(
1670 4 : f["properties"].get("id").is_none(),
1671 : "id only at Feature level"
1672 : );
1673 4 : assert!(f["properties"].get("speed").is_some());
1674 :
1675 : // geometryProperty naming a non-geometry Property -> null geometry
1676 4 : let f2 = to_geojson_feature(entity.clone(), Some(&"speed".to_string()));
1677 4 : assert_eq!(f2["geometry"], Value::Null);
1678 : // absent GeoProperty -> null geometry
1679 4 : let f3 = to_geojson_feature(entity.clone(), Some(&"missing".to_string()));
1680 4 : assert_eq!(f3["geometry"], Value::Null);
1681 :
1682 : // 4.5.17.1: simplified multi-instance GeoProperty = dataset map;
1683 : // the "@none" (default) entry is the geometry
1684 4 : let simplified = json!({
1685 4 : "id": "urn:ngsi-ld:V:2", "type": "Vehicle",
1686 4 : "location": {"dataset": {
1687 4 : "urn:ngsi-ld:Dataset:gps": {"type": "Point", "coordinates": [9.0, 9.0]},
1688 4 : "@none": {"type": "Point", "coordinates": [3.0, 4.0]}
1689 : }},
1690 4 : "speed": 5
1691 : });
1692 4 : let fs = to_geojson_feature(simplified, None);
1693 4 : assert_eq!(
1694 4 : fs["geometry"],
1695 4 : json!({"type": "Point", "coordinates": [3.0, 4.0]})
1696 : );
1697 4 : assert_eq!(fs["properties"]["speed"], 5);
1698 :
1699 4 : let fc = to_geojson_collection(vec![entity], None);
1700 4 : assert_eq!(fc["type"], "FeatureCollection");
1701 4 : assert_eq!(fc["features"].as_array().map(Vec::len), Some(1));
1702 4 : assert!(
1703 4 : fc["features"][0].get("@context").is_none(),
1704 : "no per-Feature @context"
1705 : );
1706 : // Table 5.2.30-1: "In the case that no matches are found, features
1707 : // will be an empty array"
1708 4 : let empty = to_geojson_collection(vec![], None);
1709 4 : assert_eq!(empty["type"], "FeatureCollection");
1710 4 : assert_eq!(empty["features"], json!([]));
1711 4 : }
1712 : }
|