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 : /// The narrowing's row predicate, carried down the join walk like the
39 : /// policy projection: a Linked Entity is read the way the Linking Entity
40 : /// was (5.7.1.4), so one outside the narrowing is not embedded.
41 : pub policy_reach: Option<std::sync::Arc<PolicyReach>>,
42 : /// The narrowing's rules, names expanded
43 : /// ([`type_rules`]). Each document is judged by its own rules, so this
44 : /// too travels down the join walk unchanged.
45 : pub policy_types: Option<std::sync::Arc<crate::policy::Filter>>,
46 : /// The client's own `q`, apart from the engine's ([`note_reads`]): under
47 : /// rules it is evaluated on each Entity as it is shown, so an Attribute
48 : /// a matched rule does not show is absent to it (the narrowing rules,
49 : /// 2 per Entity). `None` unless `policy_types` is set.
50 : pub policy_client_q: Option<antares_ql::QNode>,
51 : pub lang: Option<String>,
52 : /// datasetId= instance filter; entry "@none" selects default instances.
53 : pub dataset_id: Option<Vec<String>>,
54 : }
55 :
56 4722 : pub fn parse_repr(params: &HashMap<String, String>, ctx: &Context) -> Result<Repr, NgsiError> {
57 4722 : let mut r = Repr::default();
58 4722 : let mut format: Option<String> = None;
59 4722 : if let Some(opts) = params.get("options") {
60 300 : for o in opts.split(',') {
61 300 : match o.trim() {
62 300 : "sysAttrs" => r.sys_attrs = true,
63 148 : "keyValues" | "simplified" => {
64 104 : format.get_or_insert("simplified".into());
65 104 : }
66 44 : "concise" => {
67 4 : format.get_or_insert("concise".into());
68 4 : }
69 40 : "normalized" => {
70 12 : format.get_or_insert("normalized".into());
71 12 : }
72 : _ => {
73 28 : return Err(NgsiError::InvalidRequest(format!(
74 28 : "unsupported options value {o:?}"
75 28 : )))
76 : }
77 : };
78 : }
79 4434 : }
80 : // format wins over options on conflict (6.3.7)
81 4694 : if let Some(f) = params.get("format") {
82 56 : match f.as_str() {
83 56 : "normalized" | "concise" | "simplified" | "keyValues" => {
84 44 : format = Some(if f == "keyValues" {
85 28 : "simplified".into()
86 : } else {
87 16 : f.clone()
88 : })
89 : }
90 : _ => {
91 12 : return Err(NgsiError::InvalidRequest(format!(
92 12 : "unsupported format value {f:?}"
93 12 : )))
94 : }
95 : }
96 4638 : }
97 4682 : match format.as_deref() {
98 144 : Some("simplified") => r.key_values = true,
99 16 : Some("concise") => r.concise = true,
100 4554 : _ => {}
101 : }
102 4682 : check_projection_exclusive(params)?;
103 4642 : if let Some(a) = params.get("attrs") {
104 118 : let mut list = Vec::new();
105 138 : for t in a.split(',') {
106 138 : let t = t.trim();
107 138 : if t.is_empty() || ENTITY_META.contains(&t) {
108 28 : return Err(NgsiError::BadRequestData(format!(
109 28 : "invalid attribute name {t:?} in attrs"
110 28 : )));
111 110 : }
112 110 : list.push(ctx.expand_key(t));
113 : }
114 90 : r.attrs = Some(list);
115 4524 : }
116 4614 : if let Some(pck) = params.get("pick") {
117 202 : r.pick = Some(parse_projection(pck, ctx)?);
118 4412 : }
119 4554 : if let Some(o) = params.get("omit") {
120 108 : r.omit = Some(parse_projection(o, ctx)?);
121 4446 : }
122 4546 : r.lang = params.get("lang").cloned();
123 4546 : if let Some(list) = params.get("datasetId") {
124 44 : let mut ids = Vec::new();
125 58 : for d in list.split(',').map(str::trim) {
126 : // Table 6.4.3.2-1: "Shall be valid URIs, "@none" for ... the default"
127 58 : if d != "@none" && antares_model::EntityId::new(d).is_err() {
128 12 : return Err(NgsiError::BadRequestData(format!(
129 12 : "datasetId {d:?} is not a URI"
130 12 : )));
131 46 : }
132 46 : ids.push(d.to_owned());
133 : }
134 32 : r.dataset_id = Some(ids);
135 4502 : }
136 4534 : Ok(r)
137 4722 : }
138 :
139 : /// 4.5.5.1: the datasetId "is of datatype URI, or equal to the JSON-LD
140 : /// keyword `@none`", and "if no datasetId is provided, or `"datasetId":
141 : /// "@none"` is supplied, it is considered as the default Attribute
142 : /// instance". A default instance carries no datasetId of its own, so the
143 : /// write paths that select ONE instance by the `?datasetId=` parameter
144 : /// (5.6.5.4, 5.6.13.4) match `@none` against its absence. Instance members
145 : /// reach the same rule one layer earlier, in 5.5.7 expansion.
146 368 : pub fn target_dataset_id(params: &HashMap<String, String>) -> Option<&str> {
147 368 : params
148 368 : .get("datasetId")
149 368 : .map(String::as_str)
150 368 : .filter(|d| *d != "@none")
151 368 : }
152 :
153 : /// Narrow a representation by a policy decision (ADR-0020).
154 : ///
155 : /// `omit` is appended to whatever the request asked to omit: removing more
156 : /// members can only remove more. `pick` intersects with the request's,
157 : /// because a pick that added a name would serve a member the request did
158 : /// not ask for and the engine's narrowing would have widened the answer.
159 : ///
160 : /// The Entity frame survives a policy pick. 6.5.3.1 makes `pick` reduce an
161 : /// Entity "down to only contain the listed Entity members", and a client
162 : /// that wants `id` back names it; but the engine is restricting what may be
163 : /// seen rather than choosing a representation, and 5.2.4 makes `id` and
164 : /// `type` the Entity — a document without them is not one.
165 2044 : pub fn narrow_projection(
166 2044 : pick: &mut Option<Vec<ProjNode>>,
167 2044 : omit: &mut Option<Vec<ProjNode>>,
168 2044 : f: &crate::policy::Filter,
169 2044 : ctx: &Context,
170 2044 : ) -> Result<(), NgsiError> {
171 2044 : if !f.omit.is_empty() {
172 14 : let mut nodes = policy_nodes(&f.omit);
173 14 : match omit {
174 0 : Some(own) => own.append(&mut nodes),
175 14 : None => *omit = Some(nodes),
176 : }
177 2030 : }
178 2044 : if !f.pick.is_empty() {
179 0 : let allowed = policy_nodes(&f.pick);
180 0 : let mut kept = match pick.take() {
181 0 : None => allowed,
182 0 : Some(own) => own
183 0 : .into_iter()
184 0 : .filter(|n| allowed.iter().any(|a| a.iri == n.iri))
185 0 : .collect(),
186 : };
187 0 : for frame in parse_projection("id,type", ctx)? {
188 0 : if !kept.iter().any(|n| n.raw == frame.raw) {
189 0 : kept.push(frame);
190 0 : }
191 : }
192 0 : *pick = Some(kept);
193 2044 : }
194 2044 : Ok(())
195 2044 : }
196 :
197 : /// The same narrowing on a current-state [`Repr`], which is the one a join
198 : /// walk descends. It goes into the policy fields rather than into the
199 : /// request's own projection, so every document the answer carries is
200 : /// narrowed and not only the one at the top: `?join=flat` returns Entities
201 : /// reached over a Relationship, and a member the subject may not see is not
202 : /// less hidden for being one hop away.
203 : ///
204 : /// The Entity frame survives a policy pick — 5.2.4 makes `id` and `type`
205 : /// the Entity, and a document without them is not one — which is why the
206 : /// policy pick is applied to Attributes and never to the core members.
207 4538 : pub fn narrow_repr(r: &mut Repr, f: &std::sync::Arc<crate::policy::Filter>) {
208 : // A view or an engine answers every request with the same narrowing,
209 : // and the gate hands it out as one shared `Arc`: its reach is built
210 : // once and handed out again while that `Arc` comes back. The memo
211 : // holds the `Arc` itself, so its address cannot be reused by another
212 : // Filter while it is kept, and `ptr_eq` never answers for rules other
213 : // than its own (O16).
214 : type Reached = (
215 : std::sync::Arc<crate::policy::Filter>,
216 : Option<std::sync::Arc<PolicyReach>>,
217 : Option<std::sync::Arc<crate::policy::Filter>>,
218 : );
219 : thread_local! {
220 : static LAST: std::cell::RefCell<Option<Reached>> = const { std::cell::RefCell::new(None) };
221 : }
222 4538 : let hit = LAST.with_borrow(|last| {
223 4538 : last.as_ref()
224 4538 : .filter(|l| std::sync::Arc::ptr_eq(&l.0, f))
225 4538 : .map(|l| (l.1.clone(), l.2.clone()))
226 4538 : });
227 4538 : let (reach, rules) = hit.unwrap_or_else(|| {
228 1403 : let built = reach_of(f);
229 1403 : LAST.set(Some((f.clone(), built.0.clone(), built.1.clone())));
230 1403 : built
231 1403 : });
232 4538 : fill(r, f, reach, rules);
233 4538 : }
234 :
235 : /// The reach of `f` and its expanded rules, built afresh.
236 2883 : fn reach_of(
237 2883 : f: &crate::policy::Filter,
238 2883 : ) -> (
239 2883 : Option<std::sync::Arc<PolicyReach>>,
240 2883 : Option<std::sync::Arc<crate::policy::Filter>>,
241 2883 : ) {
242 2883 : let reach = crate::policy::Condition::of(f);
243 2883 : let rules = reach.as_ref().and_then(|r| r.rules.clone());
244 2883 : let reach = reach.map(std::sync::Arc::new);
245 2883 : (reach, rules)
246 2883 : }
247 :
248 6018 : fn fill(
249 6018 : r: &mut Repr,
250 6018 : f: &crate::policy::Filter,
251 6018 : reach: Option<std::sync::Arc<PolicyReach>>,
252 6018 : rules: Option<std::sync::Arc<crate::policy::Filter>>,
253 6018 : ) {
254 6018 : if reach.is_some() {
255 632 : r.policy_reach = reach;
256 5386 : }
257 6018 : if !f.omit.is_empty() {
258 86 : r.policy_omit = Some(policy_nodes(&f.omit));
259 5932 : }
260 6018 : if !f.pick.is_empty() {
261 8 : r.policy_pick = Some(policy_nodes(&f.pick));
262 6010 : }
263 6018 : r.policy_types = rules;
264 6018 : }
265 :
266 : /// The narrowing's rules with every type and Attribute name expanded
267 : /// against [`POLICY_CONTEXT`], the form an internal document's keys and
268 : /// `type` values are in; `None` when the narrowing has none. Conditions
269 : /// stay as written: [`shown`] reads them through the same context.
270 27511 : pub(crate) fn type_rules(
271 27511 : f: &crate::policy::Filter,
272 27511 : ) -> Option<std::sync::Arc<crate::policy::Filter>> {
273 27511 : if !f.has_rules() {
274 26923 : return None;
275 588 : }
276 : // The expansion is a function of the rules alone, and a view or an
277 : // engine answers every request with the same ones: the last expansion
278 : // is kept per thread and handed out again while its rules come back.
279 : // It is keyed by the whole input, so it never answers for
280 : // rules other than its own (O16).
281 : thread_local! {
282 : static LAST: std::cell::RefCell<Option<(crate::policy::Filter, std::sync::Arc<crate::policy::Filter>)>> =
283 : const { std::cell::RefCell::new(None) };
284 : }
285 588 : let hit = LAST.with_borrow(|last| {
286 588 : last.as_ref()
287 588 : .filter(|l| l.0.rules == f.rules && l.0.omit_by_type == f.omit_by_type)
288 588 : .map(|l| l.1.clone())
289 588 : });
290 588 : if hit.is_some() {
291 268 : return hit;
292 320 : }
293 1318 : let expand = |names: &[String]| -> Vec<String> {
294 1388 : names.iter().map(|n| POLICY_CONTEXT.expand_key(n)).collect()
295 1318 : };
296 320 : let expanded = std::sync::Arc::new(crate::policy::Filter {
297 320 : rules: f
298 320 : .rules
299 320 : .iter()
300 320 : .map(|r| crate::policy::Rule {
301 656 : types: expand(&r.types),
302 656 : attributes: expand(&r.attributes),
303 656 : q: r.q.clone(),
304 656 : scope_q: r.scope_q.clone(),
305 656 : geo_q: r.geo_q.clone(),
306 656 : geo_also: r.geo_also.clone(),
307 656 : ids: r.ids.clone(),
308 656 : id_patterns: r.id_patterns.clone(),
309 656 : every_attribute: r.every_attribute,
310 656 : temporal: r.temporal.clone(),
311 656 : })
312 320 : .collect(),
313 320 : omit_by_type: f
314 320 : .omit_by_type
315 320 : .iter()
316 320 : .map(|(t, attrs)| (POLICY_CONTEXT.expand_key(t), expand(attrs)))
317 320 : .collect(),
318 320 : ..crate::policy::Filter::default()
319 : });
320 320 : let key = crate::policy::Filter {
321 320 : rules: f.rules.clone(),
322 320 : omit_by_type: f.omit_by_type.clone(),
323 320 : ..crate::policy::Filter::default()
324 320 : };
325 320 : LAST.set(Some((key, expanded.clone())));
326 320 : Some(expanded)
327 27511 : }
328 :
329 : /// What the expanded `rules` let the INTERNAL document `doc` show: every
330 : /// member without rules, `None` when no rule holds for it.
331 28900 : pub(crate) fn shown<'a>(
332 28900 : rules: Option<&'a crate::policy::Filter>,
333 28900 : doc: &Value,
334 28900 : ) -> Option<crate::policy::Shown<'a>> {
335 28900 : match rules {
336 23268 : None => Some(crate::policy::Shown::default()),
337 5632 : Some(f) => f.shown(doc, &POLICY_CONTEXT),
338 : }
339 28900 : }
340 :
341 : /// The client's own `q` as the query path reads it (4.9 `expandValues`
342 : /// applied), from the request as sent.
343 364 : pub(crate) fn client_q(
344 364 : params: &HashMap<String, String>,
345 364 : ctx: &Context,
346 364 : ) -> Result<Option<antares_ql::QNode>, NgsiError> {
347 364 : params
348 364 : .get("q")
349 364 : .map(|q| {
350 64 : Ok(antares_ql::eval::apply_expand_values(
351 64 : antares_ql::parse_q(q)?,
352 64 : antares_ql::eval::expansion_list(
353 64 : params.get("expandValues").map(String::as_str),
354 64 : params.get("jsonKeys").map(String::as_str),
355 64 : )
356 64 : .as_deref(),
357 64 : ctx,
358 : ))
359 64 : })
360 364 : .transpose()
361 364 : }
362 :
363 : /// [`narrowing`] holding the client's own `q` as well, for a caller that
364 : /// runs a query under the narrowing ([`note_reads`]).
365 450 : pub(crate) fn narrowing_reading(
366 450 : f: &crate::policy::Filter,
367 450 : client_params: &HashMap<String, String>,
368 450 : ctx: &Context,
369 450 : ) -> Result<Repr, NgsiError> {
370 450 : let mut r = narrowing(f);
371 450 : note_reads(&mut r, client_params, ctx)?;
372 450 : Ok(r)
373 450 : }
374 :
375 : /// Record the client's own `q` on a narrowed representation, before the
376 : /// engine's is conjoined into the parameters; nothing to do without rules.
377 2532 : pub(crate) fn note_reads(
378 2532 : r: &mut Repr,
379 2532 : client_params: &HashMap<String, String>,
380 2532 : ctx: &Context,
381 2532 : ) -> Result<(), NgsiError> {
382 2532 : if r.policy_types.is_some() {
383 310 : r.policy_client_q = client_q(client_params, ctx)?;
384 2222 : }
385 2532 : Ok(())
386 2532 : }
387 :
388 : /// Order `docs` by what each one shows, so `orderBy` on an Attribute the
389 : /// narrowing hides orders as if it were absent
390 : /// and never by the hidden value. Without a projecting narrowing, `order`
391 : /// runs on `docs` as they are.
392 308 : pub(crate) fn order_as_shown<E>(
393 308 : docs: &mut Vec<Value>,
394 308 : narrow: Option<&Repr>,
395 308 : order: impl FnOnce(&mut Vec<Value>) -> Result<(), E>,
396 308 : ) -> Result<(), E> {
397 308 : let Some(r) = narrow
398 308 : .filter(|r| r.policy_pick.is_some() || r.policy_omit.is_some() || r.policy_types.is_some())
399 : else {
400 304 : return order(docs);
401 : };
402 : // the position travels under a key no expanded IRI or core member can
403 : // take, and comes back off the ordered views
404 : const AT: &str = " position";
405 4 : let mut views: Vec<Value> = docs
406 4 : .iter()
407 4 : .enumerate()
408 12 : .map(|(i, d)| {
409 12 : let mut v = policy_view(d, r);
410 12 : if let Some(o) = v.as_object_mut() {
411 12 : o.insert(AT.to_owned(), Value::from(i));
412 12 : }
413 12 : v
414 12 : })
415 4 : .collect();
416 4 : order(&mut views)?;
417 4 : let mut slots: Vec<Option<Value>> = std::mem::take(docs).into_iter().map(Some).collect();
418 4 : *docs = views
419 4 : .iter()
420 12 : .filter_map(|v| v.get(AT).and_then(Value::as_u64))
421 12 : .filter_map(|i| slots.get_mut(usize::try_from(i).ok()?)?.take())
422 4 : .collect();
423 4 : Ok(())
424 308 : }
425 :
426 : /// The rows a narrowing admits (ADR-0020 `Filter::q`, `scope_q` and its
427 : /// areas).
428 : #[derive(Debug, Clone)]
429 : pub struct PolicyReach {
430 : pub q: Option<antares_ql::QNode>,
431 : pub scope_q: Option<String>,
432 : /// Every area the narrowing holds; each must hold.
433 : pub areas: Vec<antares_ql::geo::GeoQuery>,
434 : /// The narrowing's expanded rules: an Entity none of them holds for is
435 : /// not admitted.
436 : pub rules: Option<std::sync::Arc<crate::policy::Filter>>,
437 : }
438 :
439 : /// Whether the narrowing admits the INTERNAL document `doc`; no narrowing
440 : /// admits everything. The one check the retrieve answer and every joined
441 : /// Entity go through, so a join cannot reach a row the retrieve hides.
442 14402 : pub(crate) async fn policy_admits(
443 14402 : st: &AppState,
444 14402 : tenant: &TenantId,
445 14402 : ctx: &Context,
446 14402 : reach: Option<&PolicyReach>,
447 14402 : doc: &Value,
448 14402 : ) -> bool {
449 14402 : let Some(reach) = reach else {
450 11626 : return true;
451 : };
452 2776 : if shown(reach.rules.as_deref(), doc).is_none() {
453 860 : return false;
454 1916 : }
455 1916 : if let Some(scope) = &reach.scope_q {
456 48 : if !crate::scope_matches(scope, doc) {
457 24 : return false;
458 24 : }
459 1868 : }
460 1892 : if !reach.areas.iter().all(|geo| geo.matches(doc, ctx)) {
461 2 : return false;
462 1890 : }
463 1890 : match &reach.q {
464 110 : Some(ast) => {
465 110 : crate::notify::linked_eval(st, tenant, None, |l| {
466 110 : antares_ql::eval::eval_q(ast, doc, ctx, l)
467 110 : })
468 110 : .await
469 : }
470 1780 : None => true,
471 : }
472 14402 : }
473 :
474 : /// The INTERNAL document `doc` as the narrowing lets it be read: the
475 : /// Entity frame and the Attributes the policy projection keeps.
476 1968 : pub(crate) fn policy_view(doc: &Value, r: &Repr) -> Value {
477 1968 : if r.policy_pick.is_none() && r.policy_omit.is_none() && r.policy_types.is_none() {
478 26 : return doc.clone();
479 1942 : }
480 1942 : let shown = shown(r.policy_types.as_deref(), doc).unwrap_or_else(crate::policy::Shown::nothing);
481 1942 : match doc.as_object() {
482 1942 : Some(obj) => Value::Object(
483 1942 : obj.iter()
484 7712 : .filter(|(k, _)| {
485 7712 : is_meta(k)
486 3352 : || (policy_projected(r.policy_pick.as_deref(), r.policy_omit.as_deref(), k)
487 3328 : && shown.allows(k))
488 7712 : })
489 6498 : .map(|(k, v)| (k.clone(), v.clone()))
490 1942 : .collect(),
491 : ),
492 0 : None => doc.clone(),
493 : }
494 1968 : }
495 :
496 : /// The narrowing half of a representation alone, for a caller that holds
497 : /// the Filter but builds no representation of its own.
498 1480 : pub fn narrowing(f: &crate::policy::Filter) -> Repr {
499 1480 : let mut r = Repr::default();
500 1480 : let (reach, rules) = reach_of(f);
501 1480 : fill(&mut r, f, reach, rules);
502 1480 : r
503 1480 : }
504 :
505 : /// The same `pick`/`omit` names in the form an already-compacted document
506 : /// carries them. The query path folds a policy projection into the
507 : /// request's own representation, where the names are IRIs; a notification
508 : /// is compacted by `notify::build_data` long before the seam sees it, so
509 : /// there the names have to travel the other way — parsed against the same
510 : /// `@context` the document was compacted with, then compacted back. An
511 : /// engine writes one rule set either way, and a rule written as an IRI
512 : /// (which is what ADR-0020 asks of an engine) removes the member it names
513 : /// rather than silently matching nothing.
514 14 : pub fn compacted_filter(f: &crate::policy::Filter, ctx: &Context) -> crate::policy::Filter {
515 28 : let names = |raw: &[String]| -> Vec<String> {
516 28 : policy_nodes(raw)
517 28 : .iter()
518 28 : .map(|n| ctx.compact_iri(&n.iri))
519 28 : .collect()
520 28 : };
521 14 : let by_type = |m: &std::collections::BTreeMap<String, Vec<String>>| {
522 14 : m.iter()
523 14 : .map(|(t, attrs)| (ctx.compact_iri(&POLICY_CONTEXT.expand_key(t)), names(attrs)))
524 14 : .collect()
525 14 : };
526 : // the rules' conditions read the stored Entity, not this compacted
527 : // one: the notification path evaluates them itself (`notify::shown_now`)
528 14 : crate::policy::Filter {
529 14 : pick: names(&f.pick),
530 14 : omit: names(&f.omit),
531 14 : rules: Vec::new(),
532 14 : omit_by_type: by_type(&f.omit_by_type),
533 14 : ..f.clone()
534 14 : }
535 14 : }
536 :
537 : /// The `@context` a policy name is read in. Deliberately NOT the request's:
538 : /// [`antares_jsonld::Context::expand_key`] consults the term map before it
539 : /// decides a name is already an IRI, so a caller that binds the term a rule
540 : /// names — or binds the rule's own IRI as a term, which its own inline
541 : /// `@context` is enough to do — would move the rule off its target and walk
542 : /// out of the narrowing. A deployment's rule means the same thing whatever
543 : /// the caller sends. ADR-0020 asks an engine to write its rules as IRIs and
544 : /// those pass through unchanged; a short name is read here the way a name a
545 : /// request does not define is read anywhere else.
546 : /// [`POLICY_CONTEXT`], for the rule conditions read outside this module.
547 8556 : pub(crate) fn policy_context() -> &'static Context {
548 8556 : &POLICY_CONTEXT
549 8556 : }
550 :
551 : static POLICY_CONTEXT: std::sync::LazyLock<Context> =
552 : std::sync::LazyLock::new(antares_jsonld::core_context);
553 :
554 : /// A policy's `pick`/`omit` names as projection nodes, expanded against
555 : /// [`POLICY_CONTEXT`].
556 : ///
557 : /// Deliberately NOT the 4.21 parser: 6.5.3.1 lets those members name `"id"`,
558 : /// `"type"`, `"scope"` or one projected Attribute, and ADR-0020 asks an
559 : /// engine to write its rules against IRIs — while 4.21 reads a dot as the
560 : /// sub-attribute path separator, so `https://uri.etsi.org/…/colour` through
561 : /// that grammar becomes the member `https://uri` and removes nothing. An
562 : /// engine names one member; it is expanded, and both forms of the name are
563 : /// kept so the projection matches a document whichever form its keys are in.
564 136 : fn policy_nodes(names: &[String]) -> Vec<ProjNode> {
565 136 : names
566 136 : .iter()
567 136 : .map(|n| ProjNode {
568 146 : raw: n.clone(),
569 146 : iri: POLICY_CONTEXT.expand_key(n),
570 146 : children: None,
571 146 : })
572 136 : .collect()
573 136 : }
574 :
575 : /// Maximum `{…}` selection depth of a projection tree — the number of
576 : /// Linked Entity hops it implies (5.7.1.4: must not exceed joinLevel).
577 582 : pub fn proj_depth(nodes: &[ProjNode]) -> usize {
578 582 : nodes
579 582 : .iter()
580 866 : .map(|n| match &n.children {
581 208 : Some(c) => proj_depth(c).saturating_add(1),
582 658 : None => 0,
583 866 : })
584 582 : .max()
585 582 : .unwrap_or(0)
586 582 : }
587 :
588 : /// The entity-level members of 4.5.1: everything an Entity carries that is
589 : /// NOT an Attribute. Every layer that has to tell the two apart reads this
590 : /// one list — `attrs`/`pick`/`omit` validation and projection here, the
591 : /// notification diff and tombstone in `notify`, the 4.3.6.8 amendment in
592 : /// `conformance`, the registration-scope narrowing in `federation`. A layer
593 : /// with its own copy is a layer that will disagree with the others about
594 : /// what an attribute is.
595 : pub(crate) const ENTITY_META: &[&str] = antares_model::ENTITY_META_KEYS;
596 :
597 : /// One node of a 4.21 attribute-projection expression; `children` carries a
598 : /// nested `{…}` selection (applied to linked entities on join).
599 : #[derive(Debug, Clone)]
600 : pub struct ProjNode {
601 : pub raw: String,
602 : pub iri: String,
603 : pub children: Option<Vec<ProjNode>>,
604 : }
605 :
606 : /// Parse + validate a pick=/omit= value (4.21) into a projection tree.
607 784 : pub(crate) fn parse_projection(s: &str, ctx: &Context) -> Result<Vec<ProjNode>, NgsiError> {
608 784 : let bad = || NgsiError::BadRequestData(format!("invalid attribute projection {s:?} (4.21)"));
609 : // Each `{…}` level is one Linked Entity hop (5.7.1.4), so a selection
610 : // deeper than the joinLevel ceiling can never be satisfied. Bounding it
611 : // here, before the recursive descent below, is what keeps the recursion
612 : // finite: pick=/omit= reach this parser as plain STRINGS — from the URI,
613 : // or from inside a query body, where the JSON nesting wall never sees
614 : // their braces.
615 784 : if s.is_empty()
616 776 : || s.matches('{').count() != s.matches('}').count()
617 736 : || crate::bounds::json_depth(s.as_bytes()) > crate::bounds::MAX_JOIN_LEVEL
618 720 : || !s
619 720 : .chars()
620 7712 : .all(|c| c.is_ascii_alphanumeric() || "_,.:{}#/%-+@|".contains(c))
621 : {
622 104 : return Err(bad());
623 680 : }
624 1004 : fn split_top(s: &str) -> Option<Vec<&str>> {
625 1004 : let mut out = Vec::new();
626 1004 : let mut depth = 0usize;
627 1004 : let mut start = 0usize;
628 10724 : for (i, c) in s.char_indices() {
629 466 : match c {
630 560 : '{' => depth = depth.saturating_add(1),
631 560 : '}' => depth = depth.checked_sub(1)?,
632 : // 4.21 orOp = | / , — both split at the same depth
633 466 : ',' | '|' if depth == 0 => {
634 428 : out.push(&s[start..i]);
635 428 : // the separator is one byte
636 428 : start = i.saturating_add(1);
637 428 : }
638 9176 : _ => {}
639 : }
640 : }
641 1004 : out.push(&s[start..]);
642 1004 : Some(out)
643 1004 : }
644 1004 : fn parse_nodes(s: &str, ctx: &Context) -> Result<Vec<ProjNode>, NgsiError> {
645 1004 : let bad = |m: &str| NgsiError::BadRequestData(format!("invalid attribute projection: {m}"));
646 1004 : let parts = split_top(s).ok_or_else(|| bad("unbalanced braces"))?;
647 1004 : let mut out: Vec<ProjNode> = Vec::new();
648 1384 : for t in parts {
649 1384 : let t = t.trim();
650 1384 : if t.is_empty() {
651 68 : return Err(bad("empty projection member"));
652 1316 : }
653 1316 : let (head_part, children) = match t.split_once('{') {
654 324 : Some((head, rest)) => {
655 324 : let inner = rest
656 324 : .strip_suffix('}')
657 324 : .ok_or_else(|| bad("unclosed brace"))?;
658 324 : (head, Some(parse_nodes(inner, ctx)?))
659 : }
660 992 : None => (t, None),
661 : };
662 : // An IRI (a `:` before any `.`) is one Attribute name (5.5.7):
663 : // the dotted path is a short name's syntax only.
664 1292 : let iri = match (head_part.find(':'), head_part.find('.')) {
665 8 : (Some(colon), Some(dot)) => colon < dot,
666 1284 : (colon, _) => colon.is_some(),
667 : };
668 1292 : let head = if iri {
669 8 : head_part
670 : } else {
671 1284 : head_part.split('.').next().unwrap_or(head_part)
672 : };
673 1292 : if head.is_empty() || (!iri && head_part.split('.').any(str::is_empty)) {
674 8 : return Err(bad("empty path segment"));
675 1284 : }
676 1284 : if !head
677 1284 : .chars()
678 1284 : .next()
679 1284 : .is_some_and(|c| c.is_ascii_alphanumeric() || "_:@#".contains(c))
680 : {
681 0 : return Err(bad("projection member starts with a special character"));
682 1284 : }
683 1284 : if out.iter().any(|n| n.raw == head) {
684 0 : return Err(bad("duplicate projection member"));
685 1284 : }
686 1284 : out.push(ProjNode {
687 1284 : raw: head.to_owned(),
688 1284 : iri: ctx.expand_key(head),
689 1284 : children,
690 1284 : });
691 : }
692 904 : Ok(out)
693 1004 : }
694 680 : parse_nodes(s, ctx)
695 784 : }
696 :
697 : /// Apply the representation to an internal doc, producing a new internal doc
698 : /// 4.21 Projections. CIM 009 V1.9.1 4.21 and TS 104 175 7.3.2 state no rule
699 : /// for combining them; the ETSI TPs 018_18, 020_22 and 028_08 refuse pick
700 : /// with attrs, omit with attrs and one member in both pick and omit. Antares
701 : /// refuses every pair (a stricter reading, kept), the one
702 : /// reading, so an operation cannot accept a combination another rejects.
703 6850 : pub fn check_projection_exclusive(params: &HashMap<String, String>) -> Result<(), NgsiError> {
704 6850 : let excl = ["pick", "omit", "attrs"]
705 6850 : .iter()
706 20550 : .filter(|k| params.contains_key(**k))
707 6850 : .count();
708 6850 : if excl > 1 {
709 80 : return Err(NgsiError::BadRequestData(
710 80 : "pick, omit and attrs cannot be combined in one request".into(),
711 80 : ));
712 6770 : }
713 6770 : Ok(())
714 6850 : }
715 :
716 : /// ADR-0020: does an Attribute survive the policy's own projection? Both
717 : /// forms of the name are tried, because a document reaches this function
718 : /// expanded on the query path and compacted on the notification path, and a
719 : /// rule names one Attribute either way.
720 36664 : pub fn policy_projected(pick: Option<&[ProjNode]>, omit: Option<&[ProjNode]>, k: &str) -> bool {
721 36664 : let names = |n: &ProjNode| n.iri == *k || n.raw == *k;
722 36664 : if let Some(pick) = pick {
723 12 : if !pick.iter().any(names) {
724 6 : return false;
725 6 : }
726 36652 : }
727 36658 : if let Some(omit) = omit {
728 174 : if omit.iter().any(names) {
729 66 : return false;
730 108 : }
731 36484 : }
732 36592 : true
733 36664 : }
734 :
735 : /// 4.21: does a core Entity member (id, type, scope, the system temporal
736 : /// Properties) survive the projection? `pick` constrains core members
737 : /// strictly — only what is named survives; `omit` drops a named member only
738 : /// when the node carries no children, because a node with children
739 : /// constrains the linked Entity below the head, not the head itself. The
740 : /// current-state and temporal representations project core members by the
741 : /// same rule and differ only below it.
742 110392 : pub fn meta_projected(pick: Option<&[ProjNode]>, omit: Option<&[ProjNode]>, k: &str) -> bool {
743 110392 : if let Some(pick) = pick {
744 1044 : if !pick.iter().any(|n| n.raw == *k) {
745 440 : return false;
746 300 : }
747 109652 : }
748 109952 : if let Some(omit) = omit {
749 918 : if omit.iter().any(|n| n.raw == *k && n.children.is_none()) {
750 140 : return false;
751 604 : }
752 109208 : }
753 109812 : true
754 110392 : }
755 :
756 : /// ready for compaction.
757 23494 : pub fn apply(doc: &Value, r: &Repr) -> Value {
758 23494 : let Some(obj) = doc.as_object() else {
759 0 : return doc.clone();
760 : };
761 : // an Entity no rule holds for was left out before it got here; one that
762 : // arrives anyway shows its frame only
763 23494 : let shown = shown(r.policy_types.as_deref(), doc).unwrap_or_else(crate::policy::Shown::nothing);
764 23494 : let mut out = Map::new();
765 115560 : for (k, v) in obj {
766 115560 : let is_meta = ENTITY_META.contains(&k.as_str());
767 115560 : if is_meta {
768 84184 : match k.as_str() {
769 : // 6.3.11 Table 6.3.11-1: expiresAt is a system temporal
770 : // attribute — included only when options=sysAttrs.
771 84184 : "createdAt" | "modifiedAt" | "expiresAt" if !r.sys_attrs => continue,
772 53240 : _ => {}
773 : }
774 : // the policy pick never reaches a core member: 5.2.4 makes id
775 : // and type the Entity, so a narrowing that removed them would
776 : // answer something that is not one
777 53240 : if !meta_projected(r.pick.as_deref(), r.omit.as_deref(), k)
778 52796 : || !meta_projected(None, r.policy_omit.as_deref(), k)
779 : {
780 444 : continue;
781 52796 : }
782 52796 : out.insert(k.clone(), v.clone());
783 52796 : continue;
784 31376 : }
785 31376 : if let Some(keep) = &r.attrs {
786 140 : if !keep.contains(k) {
787 64 : continue;
788 76 : }
789 31236 : }
790 31312 : if let Some(pick) = &r.pick {
791 1368 : if !pick.iter().any(|n| n.iri == *k) {
792 376 : continue;
793 320 : }
794 30616 : }
795 30936 : if let Some(drop) = &r.omit {
796 944 : if drop.iter().any(|n| n.iri == *k && n.children.is_none()) {
797 170 : continue;
798 506 : }
799 30260 : }
800 30766 : if !policy_projected(r.policy_pick.as_deref(), r.policy_omit.as_deref(), k)
801 30726 : || !shown.allows(k)
802 : {
803 1376 : continue;
804 29390 : }
805 29390 : let raw: Vec<Value> = v.as_array().cloned().unwrap_or_else(|| vec![v.clone()]);
806 29390 : let kept: Vec<&Value> = raw
807 29390 : .iter()
808 30070 : .filter(|inst| match (&r.dataset_id, inst.get("datasetId")) {
809 29914 : (None, _) => true,
810 108 : (Some(want), Some(Value::String(have))) => want.iter().any(|w| w == have),
811 64 : (Some(want), None) => want.iter().any(|w| w == "@none"),
812 0 : _ => false,
813 30070 : })
814 29390 : .collect();
815 29390 : let mut instances: Vec<Value> = kept
816 29390 : .iter()
817 29966 : .map(|inst| transform_instance(inst, r))
818 29390 : .collect();
819 29390 : if instances.is_empty() {
820 8 : continue;
821 29382 : }
822 29382 : if r.key_values {
823 512 : if instances.len() == 1 {
824 : // 4.5.4: a lone instance simplifies to its bare value
825 484 : out.extend(instances.pop().map(|one| (k.clone(), one)));
826 : } else {
827 : // 4.5.4 multi-attribute case: a "dataset" map holding one
828 : // key-value pair for each datasetId, "@none" for the default
829 : // instance. The pairing below is positional, so `kept` and
830 : // `instances` must both still be whole here.
831 28 : let mut ds = Map::new();
832 68 : for (orig, simple) in kept.iter().zip(instances.iter()) {
833 68 : let key = orig
834 68 : .get("datasetId")
835 68 : .and_then(Value::as_str)
836 68 : .unwrap_or("@none");
837 68 : ds.insert(key.to_owned(), simple.clone());
838 68 : }
839 28 : out.insert(
840 28 : k.clone(),
841 28 : serde_json::json!({ "dataset": Value::Object(ds) }),
842 : );
843 : }
844 28870 : } else {
845 28870 : out.insert(k.clone(), Value::Array(instances));
846 28870 : }
847 : }
848 23494 : Value::Object(out)
849 23494 : }
850 :
851 31076 : fn transform_instance(inst: &Value, r: &Repr) -> Value {
852 31076 : let Some(obj) = inst.as_object() else {
853 0 : return inst.clone();
854 : };
855 : // lang filter first: LanguageProperty → Property under a selected language
856 31076 : let mut obj = obj.clone();
857 31076 : if let Some(lang) = &r.lang {
858 52 : apply_lang(&mut obj, lang);
859 31024 : }
860 :
861 31076 : if r.key_values {
862 552 : return simplified_value(&obj);
863 30524 : }
864 :
865 30524 : let mut out = Map::new();
866 121134 : for (k, v) in &obj {
867 121134 : match k.as_str() {
868 : // 6.3.11: expiresAt shares the sysAttrs gate on attribute
869 : // instances (current-state and temporal alike).
870 121134 : "createdAt" | "modifiedAt" | "expiresAt" if !r.sys_attrs => continue,
871 65320 : "type" if r.concise => continue,
872 78962 : _ => {}
873 : }
874 : // sub-attributes recurse
875 78962 : if let Some(arr) = v.as_array().filter(|_| !is_reserved_member(k)) {
876 1106 : let subs: Vec<Value> = arr.iter().map(|i| transform_instance(i, r)).collect();
877 1106 : out.insert(k.clone(), Value::Array(subs));
878 77856 : } else {
879 77856 : out.insert(k.clone(), v.clone());
880 77856 : }
881 : }
882 30524 : if r.concise {
883 : // bare-value collapse: a Property with only `value` collapses
884 48 : if out.len() == 1 {
885 12 : if let Some(v) = out.get("value") {
886 8 : return v.clone();
887 4 : }
888 36 : }
889 30476 : }
890 30516 : Value::Object(out)
891 31076 : }
892 :
893 : /// 4.15 Language Filter on one attribute instance (5.7.2.5, and the `lang`
894 : /// rows of Tables 6.18.3.2-1 / 6.19.3.1 for the temporal forms): a
895 : /// LanguageProperty "shall be converted into a Property" holding the chosen
896 : /// languageMap entry, with the non-reified `lang` member naming it.
897 124 : pub(crate) fn apply_lang(obj: &mut Map<String, Value>, lang: &str) {
898 124 : if obj.get("type").and_then(Value::as_str) != Some("LanguageProperty") {
899 24 : return;
900 100 : }
901 100 : let Some(lm) = obj.get("languageMap").and_then(Value::as_object) else {
902 0 : return;
903 : };
904 100 : if let Some((chosen_lang, value)) = select_lang(lm, lang) {
905 100 : obj.remove("languageMap");
906 100 : obj.insert("type".into(), Value::String("Property".into()));
907 100 : obj.insert("value".into(), value);
908 100 : obj.insert("lang".into(), Value::String(chosen_lang));
909 100 : }
910 124 : }
911 :
912 : /// 4.15 Language Filter: pick one languageMap entry for a lang priority
913 : /// list. Ranges are ordered by their q weights (RFC 3282, default 1, list
914 : /// position breaking ties); tags compare case-insensitively (RFC 5646); a
915 : /// range matches an exact tag, then a longer tag by prefix (fr → fr-CH),
916 : /// then a shorter tag by truncation (fr-CH → fr). "*" — or no match at
917 : /// all — "shall default to any supported language" (@none preferred).
918 136 : fn select_lang(lm: &Map<String, Value>, lang: &str) -> Option<(String, Value)> {
919 136 : let mut ranges: Vec<(f64, usize, &str)> = lang
920 136 : .split(',')
921 136 : .enumerate()
922 168 : .filter_map(|(i, part)| {
923 168 : let mut it = part.trim().split(';');
924 168 : let tag = it.next()?.trim();
925 168 : if tag.is_empty() {
926 0 : return None;
927 168 : }
928 168 : let q = it
929 168 : .find_map(|p| {
930 40 : p.trim()
931 40 : .strip_prefix("q=")
932 40 : .and_then(|v| v.parse::<f64>().ok())
933 40 : })
934 168 : .unwrap_or(1.0);
935 168 : Some((q, i, tag))
936 168 : })
937 136 : .collect();
938 136 : ranges.sort_by(|a, b| {
939 32 : b.0.partial_cmp(&a.0)
940 32 : .unwrap_or(std::cmp::Ordering::Equal)
941 32 : .then(a.1.cmp(&b.1))
942 32 : });
943 248 : let ci = |k: &str, want: &str| k.eq_ignore_ascii_case(want);
944 140 : for (q, _, want) in &ranges {
945 : // q=0 = "not acceptable" (RFC 3282); "*" = any → the fallback below
946 140 : if *q <= 0.0 || *want == "*" {
947 12 : continue;
948 128 : }
949 216 : if let Some((k, v)) = lm.iter().find(|(k, _)| ci(k, want)) {
950 80 : return Some((k.clone(), v.clone()));
951 48 : }
952 88 : if let Some((k, v)) = lm.iter().find(|(k, _)| {
953 88 : k.len() > want.len()
954 8 : && k.as_bytes().get(want.len()) == Some(&b'-')
955 8 : && ci(&k[..want.len()], want)
956 88 : }) {
957 8 : return Some((k.clone(), v.clone()));
958 40 : }
959 40 : let mut w = *want;
960 40 : while let Some(cut) = w.rfind('-') {
961 16 : w = &w[..cut];
962 24 : if let Some((k, v)) = lm.iter().find(|(k, _)| ci(k, w)) {
963 16 : return Some((k.clone(), v.clone()));
964 0 : }
965 : }
966 : }
967 : // any: prefer @none, then first
968 32 : if let Some(v) = lm.get("@none") {
969 0 : return Some(("@none".to_owned(), v.clone()));
970 32 : }
971 32 : lm.iter().next().map(|(k, v)| (k.clone(), v.clone()))
972 136 : }
973 :
974 1150 : fn is_reserved_member(k: &str) -> bool {
975 44 : matches!(
976 1150 : k,
977 1150 : "type"
978 1150 : | "value"
979 1144 : | "object"
980 1144 : | "objectType"
981 1144 : | "datasetId"
982 1144 : | "observedAt"
983 1144 : | "unitCode"
984 1144 : | "lang"
985 1144 : | "languageMap"
986 1144 : | "vocab"
987 1140 : | "json"
988 1132 : | "valueList"
989 1122 : | "objectList"
990 1110 : | "createdAt"
991 1110 : | "modifiedAt"
992 1110 : | "deletedAt"
993 1110 : | "instanceId"
994 1110 : | "previousValue"
995 1106 : | "previousObject"
996 1106 : | "previousLanguageMap"
997 1106 : | "previousJson"
998 1106 : | "previousVocab"
999 : )
1000 1150 : }
1001 :
1002 : /// 4.5.4: the simplified (keyValues) value of one instance — bare value for
1003 : /// Property/GeoProperty, bare URI(s) for a Relationship, bare ordered arrays
1004 : /// for ListProperty/ListRelationship, but the single-key wrapper objects
1005 : /// {"languageMap": …} / {"json": …} / {"vocab": …} for the Language, Json
1006 : /// and Vocab subtypes (Examples 4–6).
1007 580 : fn simplified_value(obj: &Map<String, Value>) -> Value {
1008 892 : for k in ["value", "object", "valueList", "objectList"] {
1009 892 : if let Some(v) = obj.get(k) {
1010 540 : return v.clone();
1011 352 : }
1012 : }
1013 76 : for k in ["languageMap", "json", "vocab"] {
1014 76 : if let Some(v) = obj.get(k) {
1015 40 : return serde_json::json!({ k: v.clone() });
1016 36 : }
1017 : }
1018 0 : Value::Object(obj.clone())
1019 580 : }
1020 :
1021 : /// Compaction for a shaped doc under a representation: keyValues docs get
1022 : /// shallow key renaming only (values are already plain JSON).
1023 23412 : pub fn compact_for(
1024 23412 : repr: &crate::repr::Repr,
1025 23412 : shaped: &Value,
1026 23412 : ctx: &antares_jsonld::Context,
1027 23412 : ) -> Value {
1028 23412 : if repr.key_values {
1029 316 : compact_entity_shallow(shaped, ctx)
1030 : } else {
1031 23096 : compact_entity(shaped, ctx)
1032 : }
1033 23412 : }
1034 :
1035 : /// The child representation for a linked entity under `key` (4.21 nested
1036 : /// projections apply to the joined entity, not the relationship itself).
1037 6608 : fn joined_repr(parent: &crate::repr::Repr, key_compact: &str, key_iri: &str) -> crate::repr::Repr {
1038 6608 : let mut r = crate::repr::Repr {
1039 6608 : sys_attrs: parent.sys_attrs,
1040 6608 : key_values: parent.key_values,
1041 6608 : concise: parent.concise,
1042 6608 : lang: parent.lang.clone(),
1043 6608 : // ADR-0020: the narrowing is not a per-level projection — it
1044 6608 : // travels to every document the answer carries
1045 6608 : policy_pick: parent.policy_pick.clone(),
1046 6608 : policy_omit: parent.policy_omit.clone(),
1047 6608 : policy_reach: parent.policy_reach.clone(),
1048 6608 : policy_types: parent.policy_types.clone(),
1049 6608 : ..Default::default()
1050 6608 : };
1051 6608 : if let Some(pick) = &parent.pick {
1052 176 : if let Some(n) = pick
1053 176 : .iter()
1054 402 : .find(|n| n.raw == key_compact || n.iri == key_iri)
1055 176 : {
1056 176 : r.pick = n.children.clone();
1057 176 : }
1058 6432 : }
1059 6608 : if let Some(omit) = &parent.omit {
1060 248 : if let Some(n) = omit
1061 248 : .iter()
1062 392 : .find(|n| (n.raw == key_compact || n.iri == key_iri) && n.children.is_some())
1063 90 : {
1064 90 : r.omit = n.children.clone();
1065 158 : }
1066 6360 : }
1067 6608 : r
1068 6608 : }
1069 :
1070 : /// 4.5.23.1: "When retrieving Linked Entities, it is necessary to limit
1071 : /// retrieval to avoid cascades of an excessive length, duplicates or loops."
1072 : /// joinLevel bounds the DEPTH of the walk; this bounds its WIDTH — the total
1073 : /// number of Linked Entity reads a single request may buy, so that a densely
1074 : /// linked graph cannot turn one retrieval into an unbounded store scan.
1075 : pub(crate) const MAX_JOIN_LOOKUPS: usize = 1_000;
1076 :
1077 : /// State of one Linked Entity Retrieval walk (4.5.23.1): the entity ids
1078 : /// already resolved — a loop or a duplicate is never walked a second time —
1079 : /// and the remaining lookup budget. `complete` goes false as soon as the walk
1080 : /// left something out, which the caller reports as an NGSILD-Warning.
1081 : struct JoinWalk {
1082 : seen: std::collections::BTreeSet<String>,
1083 : budget: usize,
1084 : complete: bool,
1085 : /// The request's headers when distributed operations are on: a target
1086 : /// held by a registered source is then read through its registration.
1087 : remote: Option<axum::http::HeaderMap>,
1088 : /// simplified (keyValues): the Linking Entity's Relationships, read off
1089 : /// the Entity it was rendered from; taken by the walk's first level.
1090 : root_links: Option<std::collections::BTreeSet<String>>,
1091 : }
1092 :
1093 : impl JoinWalk {
1094 : /// The Linking Entity is already part of the response, so it counts as
1095 : /// resolved before the walk starts — and so does every id the client
1096 : /// passed in `containedBy`. `budget` is what is LEFT of the request's
1097 : /// allowance: a page walks one entity at a time and each walk hands the
1098 : /// remainder to the next, so the ceiling bounds the request rather than
1099 : /// each of its entities.
1100 486 : fn rooted(root: Option<&str>, contained_by: &[String], budget: usize) -> Self {
1101 486 : let mut seen: std::collections::BTreeSet<String> = contained_by.iter().cloned().collect();
1102 486 : if let Some(id) = root {
1103 486 : seen.insert(id.to_owned());
1104 486 : }
1105 486 : JoinWalk {
1106 486 : seen,
1107 486 : budget,
1108 486 : complete: true,
1109 486 : remote: None,
1110 486 : root_links: None,
1111 486 : }
1112 486 : }
1113 : }
1114 :
1115 : /// Linked Entity Retrieval, inline form (4.5.23.2): embed each relationship
1116 : /// target under an "entity" member (normalized) or replace the object URI by
1117 : /// the linked entity representation (simplified). Operates on COMPACTED docs;
1118 : /// `stored` is the Entity `compacted` was rendered from, in its internal
1119 : /// form, which still tells a Relationship from a Property holding a URI.
1120 : /// Returns false when 4.5.23.1 truncated the walk (loop, duplicate, budget).
1121 36 : pub async fn inline_join(
1122 36 : st: &AppState,
1123 36 : tenant: &TenantId,
1124 36 : ctx: &antares_jsonld::Context,
1125 36 : repr: &crate::repr::Repr,
1126 36 : compacted: &mut Value,
1127 36 : stored: &Value,
1128 36 : level: usize,
1129 36 : ) -> bool {
1130 36 : inline_join_beyond(
1131 36 : st,
1132 36 : tenant,
1133 36 : ctx,
1134 36 : repr,
1135 36 : compacted,
1136 36 : stored,
1137 36 : level,
1138 36 : &[],
1139 36 : &mut { MAX_JOIN_LOOKUPS },
1140 36 : None,
1141 36 : )
1142 36 : .await
1143 36 : }
1144 :
1145 : /// Same, continuing an Entity Graph the client is already holding: the
1146 : /// `containedBy` ids count as encountered (Table 6.4.3.2-1). `remote` (the
1147 : /// request's headers, `None` with distributed operations off) lets a target
1148 : /// held by a registered source join too.
1149 : #[allow(clippy::too_many_arguments)] // one param per piece of the traversal's state
1150 264 : pub async fn inline_join_beyond(
1151 264 : st: &AppState,
1152 264 : tenant: &TenantId,
1153 264 : ctx: &antares_jsonld::Context,
1154 264 : repr: &crate::repr::Repr,
1155 264 : compacted: &mut Value,
1156 264 : stored: &Value,
1157 264 : level: usize,
1158 264 : contained_by: &[String],
1159 264 : budget: &mut usize,
1160 264 : remote: Option<&axum::http::HeaderMap>,
1161 264 : ) -> bool {
1162 264 : let mut walk = JoinWalk::rooted(
1163 264 : stored
1164 264 : .get("id")
1165 264 : .or_else(|| compacted.get("id"))
1166 264 : .and_then(Value::as_str),
1167 264 : contained_by,
1168 264 : *budget,
1169 : );
1170 264 : walk.remote = remote.cloned();
1171 : // simplified, the Linking Entity's Relationships are read off the form
1172 : // it was rendered from: a re-read by the rendered `id` found nothing
1173 : // when a projection had taken the `id` out (every URI-valued Property
1174 : // was then joined) or when the Entity came from a Context Source
1175 264 : walk.root_links = repr.key_values.then(|| linking_attrs(stored));
1176 264 : inline_join_walk(st, tenant, ctx, repr, compacted, level, &mut walk).await;
1177 264 : *budget = walk.budget;
1178 264 : walk.complete
1179 264 : }
1180 :
1181 : /// One place in a compacted document where Linked Entities join: the JSON
1182 : /// pointer of a Relationship instance (or, simplified, of its URI string),
1183 : /// the representation its targets take, how they are written, and their ids.
1184 : struct Slot {
1185 : at: String,
1186 : child: crate::repr::Repr,
1187 : kind: SlotKind,
1188 : targets: Vec<String>,
1189 : /// The Relationship's objectType (4.5.23.1), as written.
1190 : object_type: Option<String>,
1191 : }
1192 :
1193 : enum SlotKind {
1194 : /// 4.5.23.2 normalized: the targets go under "entity".
1195 : Entity,
1196 : /// 4.5.22.2: a ListRelationship's targets go under "entityList".
1197 : EntityList,
1198 : /// simplified: the URI string itself is replaced by the target.
1199 : Replace,
1200 : }
1201 :
1202 : /// A key as one JSON pointer segment (RFC 6901: `~` and `/` escaped).
1203 4168 : fn segment(key: &str) -> String {
1204 4168 : key.replace('~', "~0").replace('/', "~1")
1205 4168 : }
1206 :
1207 : /// The join slots of one compacted document, in document order. `links`
1208 : /// = the expanded Attributes of the Entity that are Relationships, which a
1209 : /// simplified document can no longer tell from a Property holding a URI
1210 : /// string ([`linking_attrs`]); `None` for a normalized one, whose
1211 : /// instances say it themselves.
1212 366 : fn slots_of(
1213 366 : ctx: &antares_jsonld::Context,
1214 366 : repr: &crate::repr::Repr,
1215 366 : doc: &Value,
1216 366 : at: &str,
1217 366 : links: Option<&std::collections::BTreeSet<String>>,
1218 366 : ) -> Vec<Slot> {
1219 366 : let mut out = Vec::new();
1220 366 : let Some(obj) = doc.as_object() else {
1221 0 : return out;
1222 : };
1223 366 : let metas = ["id", "type", "scope", "createdAt", "modifiedAt", "@context"];
1224 4864 : for (k, v) in obj {
1225 4864 : if metas.contains(&k.as_str()) {
1226 684 : continue;
1227 4180 : }
1228 4180 : let iri = ctx.expand_key(k);
1229 4180 : if links.is_some_and(|l| !l.contains(&iri)) {
1230 20 : continue;
1231 4160 : }
1232 4160 : let child = joined_repr(repr, k, &iri);
1233 4160 : slots_in(repr, &child, v, format!("{at}/{}", segment(k)), &mut out);
1234 : }
1235 366 : out
1236 366 : }
1237 :
1238 4168 : fn slots_in(
1239 4168 : repr: &crate::repr::Repr,
1240 4168 : child: &crate::repr::Repr,
1241 4168 : v: &Value,
1242 4168 : at: String,
1243 4168 : out: &mut Vec<Slot>,
1244 4168 : ) {
1245 4168 : let slot = |kind, targets, object_type| Slot {
1246 3994 : at: at.clone(),
1247 3994 : child: child.clone(),
1248 3994 : kind,
1249 3994 : targets,
1250 3994 : object_type,
1251 3994 : };
1252 68 : match v {
1253 0 : Value::Array(items) => {
1254 0 : for (i, item) in items.iter().enumerate() {
1255 0 : slots_in(repr, child, item, format!("{at}/{i}"), out);
1256 0 : }
1257 : }
1258 : // 4.5.4 simplified multi-attribute: one value per datasetId
1259 4100 : Value::Object(o) if repr.key_values => {
1260 4 : if let Some(Value::Object(ds)) = o.get("dataset") {
1261 8 : for (id, one) in ds {
1262 8 : slots_in(
1263 8 : repr,
1264 8 : child,
1265 8 : one,
1266 8 : format!("{at}/dataset/{}", segment(id)),
1267 8 : out,
1268 8 : );
1269 8 : }
1270 0 : }
1271 : }
1272 4096 : Value::Object(inst) => {
1273 : // 4.5.22.2: the compacted objectList carries {"object": URI}
1274 : // entries (or bare URIs).
1275 4096 : if let Some(Value::Array(ol)) = inst.get("objectList") {
1276 2 : let targets = ol
1277 2 : .iter()
1278 4 : .filter_map(|e| match e {
1279 0 : Value::String(id) => Some(id.clone()),
1280 4 : Value::Object(o) => {
1281 4 : o.get("object").and_then(Value::as_str).map(str::to_owned)
1282 : }
1283 0 : _ => None,
1284 4 : })
1285 2 : .collect();
1286 2 : out.push(slot(SlotKind::EntityList, targets, None));
1287 2 : return;
1288 4094 : }
1289 4094 : let targets = match inst.get("object") {
1290 3920 : Some(Value::String(id)) => vec![id.clone()],
1291 4 : Some(Value::Array(a)) => a
1292 4 : .iter()
1293 4 : .filter_map(Value::as_str)
1294 4 : .map(str::to_owned)
1295 4 : .collect(),
1296 170 : _ => return,
1297 : };
1298 3924 : let object_type = match inst.get("objectType") {
1299 8 : Some(Value::String(t)) => Some(t.clone()),
1300 3916 : _ => None,
1301 : };
1302 3924 : out.push(slot(SlotKind::Entity, targets, object_type));
1303 : }
1304 68 : Value::String(id) if repr.key_values => {
1305 68 : out.push(slot(SlotKind::Replace, vec![id.clone()], None));
1306 68 : }
1307 0 : _ => {}
1308 : }
1309 4168 : }
1310 :
1311 : /// TS 104 175 7.7.4 Depth of retrieval: the walk goes breadth-first, one
1312 : /// depth at a time, so the first time an Entity is met is at its shallowest
1313 : /// depth, and that is where its own Linked Entities expand; every later
1314 : /// encounter embeds it without walking it again (4.5.23.1). Depth-first, an
1315 : /// Entity met first deep spent its one expansion there and stayed bare at
1316 : /// depth 1. The lookup budget and the `seen` set are the same ones.
1317 264 : async fn inline_join_walk(
1318 264 : st: &AppState,
1319 264 : tenant: &TenantId,
1320 264 : ctx: &antares_jsonld::Context,
1321 264 : repr: &crate::repr::Repr,
1322 264 : compacted: &mut Value,
1323 264 : level: usize,
1324 264 : walk: &mut JoinWalk,
1325 264 : ) {
1326 264 : let links = walk.root_links.take();
1327 264 : let mut frontier = vec![(String::new(), repr.clone(), level, links)];
1328 620 : while !frontier.is_empty() {
1329 356 : let mut next = Vec::new();
1330 366 : for (at, repr, level, links) in frontier {
1331 366 : let slots = match compacted.pointer(&at) {
1332 366 : Some(doc) => slots_of(ctx, &repr, doc, &at, links.as_ref()),
1333 0 : None => continue,
1334 : };
1335 3994 : for slot in slots {
1336 3994 : let mut joined = Vec::new();
1337 8392 : for id in &slot.targets {
1338 7548 : if let Some(found) =
1339 8392 : lookup_joined(st, tenant, ctx, &slot, id, level, walk).await
1340 7548 : {
1341 7548 : joined.push(found);
1342 7548 : }
1343 : }
1344 3994 : if joined.is_empty() {
1345 444 : continue;
1346 3550 : }
1347 3550 : let Some(place) = compacted.pointer_mut(&slot.at) else {
1348 0 : continue;
1349 : };
1350 3550 : let single = joined.len() == 1;
1351 3550 : let (docs, expand): (Vec<Value>, Vec<(bool, Option<_>)>) =
1352 7548 : joined.into_iter().map(|(d, e, l)| (d, (e, l))).unzip();
1353 3550 : let at = match slot.kind {
1354 : SlotKind::Replace => {
1355 68 : *place = docs.into_iter().next().unwrap_or(Value::Null);
1356 68 : vec![slot.at.clone()]
1357 : }
1358 : SlotKind::EntityList => {
1359 2 : let count = docs.len();
1360 2 : if let Some(inst) = place.as_object_mut() {
1361 2 : inst.insert("entityList".into(), Value::Array(docs));
1362 2 : }
1363 2 : (0..count)
1364 4 : .map(|i| format!("{}/entityList/{i}", slot.at))
1365 2 : .collect()
1366 : }
1367 3476 : SlotKind::Entity if single => {
1368 3476 : if let Some(inst) = place.as_object_mut() {
1369 3476 : inst.insert(
1370 3476 : "entity".into(),
1371 3476 : docs.into_iter().next().unwrap_or(Value::Null),
1372 3476 : );
1373 3476 : }
1374 3476 : vec![format!("{}/entity", slot.at)]
1375 : }
1376 : SlotKind::Entity => {
1377 4 : let count = docs.len();
1378 4 : if let Some(inst) = place.as_object_mut() {
1379 4 : inst.insert("entity".into(), Value::Array(docs));
1380 4 : }
1381 4 : (0..count)
1382 4000 : .map(|i| format!("{}/entity/{i}", slot.at))
1383 4 : .collect()
1384 : }
1385 : };
1386 7548 : for (child_at, (expand, links)) in at.into_iter().zip(expand) {
1387 7548 : if expand {
1388 102 : next.push((child_at, slot.child.clone(), level.saturating_sub(1), links));
1389 7446 : }
1390 : }
1391 : }
1392 : }
1393 356 : frontier = next;
1394 : }
1395 264 : }
1396 :
1397 : /// The expanded Attributes of an Entity (as stored) that are Relationships
1398 : /// or ListRelationships: an instance with an `object` or an `objectList`.
1399 112 : fn linking_attrs(doc: &Value) -> std::collections::BTreeSet<String> {
1400 112 : doc.as_object()
1401 112 : .into_iter()
1402 112 : .flatten()
1403 544 : .filter(|(k, v)| {
1404 544 : !is_meta(k)
1405 96 : && v.as_array().is_some_and(|insts| {
1406 96 : insts
1407 96 : .iter()
1408 96 : .any(|i| i.get("object").is_some() || i.get("objectList").is_some())
1409 96 : })
1410 544 : })
1411 112 : .map(|(k, _)| k.clone())
1412 112 : .collect()
1413 112 : }
1414 :
1415 : /// One Linked Entity read under the walk's budget and the narrowing: the
1416 : /// compacted target, whether its own links are expanded (the first time
1417 : /// it is met, while depth remains) and, simplified, its Relationships
1418 : /// ([`slots_of`]). A target outside the narrowing reads as absent, like a
1419 : /// dangling link.
1420 8392 : async fn lookup_joined(
1421 8392 : st: &AppState,
1422 8392 : tenant: &TenantId,
1423 8392 : ctx: &antares_jsonld::Context,
1424 8392 : slot: &Slot,
1425 8392 : id: &str,
1426 8392 : level: usize,
1427 8392 : walk: &mut JoinWalk,
1428 8392 : ) -> Option<(Value, bool, Option<std::collections::BTreeSet<String>>)> {
1429 8392 : if walk.budget == 0 {
1430 800 : walk.complete = false;
1431 800 : return None;
1432 7592 : }
1433 7592 : walk.budget = walk.budget.saturating_sub(1);
1434 7592 : let child = &slot.child;
1435 7592 : let local = st.store.get(tenant, Kind::Entity, id).await.ok().flatten();
1436 7592 : let target = match local {
1437 7574 : Some(t) => t,
1438 18 : None => remote_target(st, tenant, ctx, slot, id, walk).await?,
1439 : };
1440 7576 : if !policy_admits(st, tenant, ctx, child.policy_reach.as_deref(), &target).await {
1441 28 : return None;
1442 7548 : }
1443 7548 : let shaped = apply(&target, child);
1444 7548 : let c = compact_for(child, &shaped, ctx);
1445 7548 : let links = child.key_values.then(|| linking_attrs(&target));
1446 7548 : let mut expand = false;
1447 7548 : if level > 1 {
1448 136 : if walk.seen.insert(id.to_owned()) {
1449 102 : expand = true;
1450 102 : } else {
1451 34 : // 4.5.23.1: an already-resolved target is a loop or a duplicate —
1452 34 : // it is still embedded, but its own links are not walked again.
1453 34 : walk.complete = false;
1454 34 : }
1455 7412 : }
1456 7548 : Some((c, expand, links))
1457 8392 : }
1458 :
1459 : /// 4.5.23.1 (TS 104 175 7.7.1): "Only Relationships targeting a locally
1460 : /// stored Entity or Relationships annotated with an objectType whose object
1461 : /// is an Internal Linked Entity are considered to be retrievable in this
1462 : /// manner." A target not stored here joins only when the Relationship names
1463 : /// its objectType and a registration holds that id under that type; it is
1464 : /// then read as a distributed Retrieve Entity.
1465 18 : async fn remote_target(
1466 18 : st: &AppState,
1467 18 : tenant: &TenantId,
1468 18 : ctx: &antares_jsonld::Context,
1469 18 : slot: &Slot,
1470 18 : id: &str,
1471 18 : walk: &JoinWalk,
1472 18 : ) -> Option<Value> {
1473 18 : let (headers, object_type) = (walk.remote.as_ref()?, slot.object_type.as_ref()?);
1474 4 : let spec = crate::registry::CsrSpec {
1475 4 : ids: Some(vec![id.to_owned()]),
1476 4 : types: Some(vec![ctx.expand_key(object_type)]),
1477 4 : ..Default::default()
1478 4 : };
1479 4 : let regs = crate::federation::matching_regs(st, tenant, &spec, ctx, headers)
1480 4 : .await
1481 4 : .ok()?;
1482 4 : if regs.is_empty() {
1483 2 : return None;
1484 2 : }
1485 2 : let mut warnings = Vec::new();
1486 2 : let fed = crate::federation::fed_retrieve(
1487 2 : st,
1488 2 : tenant,
1489 2 : headers,
1490 2 : ctx,
1491 2 : id,
1492 2 : None,
1493 2 : None,
1494 2 : &mut warnings,
1495 2 : &mut None,
1496 2 : )
1497 2 : .await
1498 2 : .ok()?;
1499 2 : let mut base = fed
1500 2 : .iter()
1501 2 : .find(|(aux, _)| !aux)
1502 2 : .or_else(|| fed.first())
1503 2 : .map(|(_, d)| d.clone())?;
1504 4 : for aux_pass in [false, true] {
1505 4 : for (aux, d) in &fed {
1506 4 : if *aux == aux_pass {
1507 2 : crate::federation::merge_docs(&mut base, d, *aux);
1508 2 : }
1509 : }
1510 : }
1511 2 : Some(base)
1512 18 : }
1513 :
1514 : /// Linked Entity Retrieval, flattened form (4.5.23.3): collect targets with
1515 : /// the child representation that applies to each. The Linking Entity is
1516 : /// already in the flattened array, so 4.5.23.1 ("avoid ... duplicates or
1517 : /// loops") keeps it out of `out` even when a Relationship points back at it.
1518 : /// Returns false when the walk was truncated by the lookup budget.
1519 24 : pub async fn collect_flat(
1520 24 : st: &AppState,
1521 24 : tenant: &TenantId,
1522 24 : ctx: &Context,
1523 24 : repr: &crate::repr::Repr,
1524 24 : internal_doc: &Value,
1525 24 : level: usize,
1526 24 : out: &mut std::collections::BTreeMap<String, (Value, crate::repr::Repr)>,
1527 24 : ) -> bool {
1528 24 : collect_flat_beyond(st, tenant, ctx, repr, internal_doc, level, out, &[], &mut {
1529 24 : MAX_JOIN_LOOKUPS
1530 24 : })
1531 24 : .await
1532 24 : }
1533 :
1534 : /// Same, continuing an Entity Graph the client is already holding: the
1535 : /// `containedBy` ids count as encountered (Table 6.4.3.2-1).
1536 : #[allow(clippy::too_many_arguments)] // one param per piece of the traversal's state
1537 222 : pub async fn collect_flat_beyond(
1538 222 : st: &AppState,
1539 222 : tenant: &TenantId,
1540 222 : ctx: &Context,
1541 222 : repr: &crate::repr::Repr,
1542 222 : internal_doc: &Value,
1543 222 : level: usize,
1544 222 : out: &mut std::collections::BTreeMap<String, (Value, crate::repr::Repr)>,
1545 222 : contained_by: &[String],
1546 222 : budget: &mut usize,
1547 222 : ) -> bool {
1548 222 : let mut walk = JoinWalk::rooted(
1549 222 : internal_doc.get("id").and_then(Value::as_str),
1550 222 : contained_by,
1551 222 : *budget,
1552 : );
1553 222 : walk.seen.extend(out.keys().cloned());
1554 222 : collect_flat_walk(st, tenant, ctx, repr, internal_doc, level, out, &mut walk).await;
1555 222 : *budget = walk.budget;
1556 222 : walk.complete
1557 222 : }
1558 :
1559 : /// Breadth-first like [`inline_join_walk`] (TS 104 175 7.7.4): every Entity
1560 : /// of one depth is collected before the next depth is read, so an Entity is
1561 : /// walked from its shallowest occurrence and its Linked Entities get the
1562 : /// depth that occurrence leaves.
1563 : #[allow(clippy::too_many_arguments)] // one param per piece of the traversal's state
1564 222 : async fn collect_flat_walk(
1565 222 : st: &AppState,
1566 222 : tenant: &TenantId,
1567 222 : ctx: &Context,
1568 222 : repr: &crate::repr::Repr,
1569 222 : internal_doc: &Value,
1570 222 : level: usize,
1571 222 : out: &mut std::collections::BTreeMap<String, (Value, crate::repr::Repr)>,
1572 222 : walk: &mut JoinWalk,
1573 222 : ) {
1574 222 : let mut frontier = vec![(internal_doc.clone(), repr.clone(), level)];
1575 534 : while !frontier.is_empty() {
1576 314 : let mut next = Vec::new();
1577 316 : for (doc, repr, level) in &frontier {
1578 316 : let Some(obj) = doc.as_object() else {
1579 0 : continue;
1580 : };
1581 316 : let shown = shown(repr.policy_types.as_deref(), doc)
1582 316 : .unwrap_or_else(crate::policy::Shown::nothing);
1583 3846 : for (k, v) in obj {
1584 3846 : if is_meta(k) {
1585 1270 : continue;
1586 2576 : }
1587 : // only traverse relationships that survive THIS doc's projection
1588 2576 : if let Some(pick) = &repr.pick {
1589 364 : if !pick.iter().any(|n| n.iri == *k || n.raw == *k) {
1590 84 : continue;
1591 84 : }
1592 2408 : }
1593 2492 : if let Some(omit) = &repr.omit {
1594 160 : if omit
1595 160 : .iter()
1596 232 : .any(|n| (n.iri == *k || n.raw == *k) && n.children.is_none())
1597 : {
1598 40 : continue;
1599 120 : }
1600 2332 : }
1601 : // a Relationship the subject may not see is not a road either
1602 2452 : if !policy_projected(repr.policy_pick.as_deref(), repr.policy_omit.as_deref(), k)
1603 2450 : || !shown.allows(k)
1604 : {
1605 4 : continue;
1606 2448 : }
1607 2448 : let Some(instances) = v.as_array() else {
1608 0 : continue;
1609 : };
1610 2448 : let child = joined_repr(repr, k, k);
1611 2448 : for inst in instances {
1612 : // Relationship objects plus ListRelationship objectList targets
1613 : // (internal form stores bare URIs) — 4.5.23.3 appends both kinds
1614 : // of Linked Entities to the flattened array.
1615 2448 : let targets: Vec<&str> = match (inst.get("object"), inst.get("objectList")) {
1616 2312 : (Some(Value::String(id)), _) => vec![id.as_str()],
1617 0 : (Some(Value::Array(a)), _) => a.iter().filter_map(Value::as_str).collect(),
1618 2 : (None, Some(Value::Array(a))) => {
1619 2 : a.iter().filter_map(Value::as_str).collect()
1620 : }
1621 134 : _ => continue,
1622 : };
1623 2316 : for id in targets {
1624 2316 : if walk.seen.contains(id) {
1625 12 : continue;
1626 2304 : }
1627 2304 : if walk.budget == 0 {
1628 2 : walk.complete = false;
1629 2 : return;
1630 2302 : }
1631 2302 : walk.budget = walk.budget.saturating_sub(1);
1632 2298 : let Some(target) =
1633 2302 : st.store.get(tenant, Kind::Entity, id).await.ok().flatten()
1634 : else {
1635 4 : continue;
1636 : };
1637 2298 : if !policy_admits(st, tenant, ctx, child.policy_reach.as_deref(), &target)
1638 2298 : .await
1639 : {
1640 26 : continue;
1641 2272 : }
1642 2272 : walk.seen.insert(id.to_owned());
1643 2272 : out.insert(id.to_owned(), (target.clone(), child.clone()));
1644 2272 : if *level > 1 {
1645 94 : next.push((target, child.clone(), level.saturating_sub(1)));
1646 2178 : }
1647 : }
1648 : }
1649 : }
1650 : }
1651 312 : frontier = next;
1652 : }
1653 222 : }
1654 :
1655 : /// 4.5.16.2 GeoJSON Feature, members per Table 5.2.29-1 (5.2.29 Feature):
1656 : /// id = entity id (URI), fixed type "Feature", geometry = the selected
1657 : /// GeoProperty's value or null (4.5.16.1: geometryProperty parameter,
1658 : /// default "location"), properties = the 5.2.31 FeatureProperties (entity
1659 : /// type + attributes). The @context member is added by respond() (6.3.6).
1660 98 : pub fn to_geojson_feature(entity: Value, geometry_property: Option<&String>) -> Value {
1661 98 : let geometry = geometry_of(&entity, geometry_property.map(String::as_str));
1662 98 : let id = entity.get("id").cloned().unwrap_or(Value::Null);
1663 98 : let mut props = entity.as_object().cloned().unwrap_or_default();
1664 98 : props.remove("id");
1665 98 : let mut feature = Map::new();
1666 98 : feature.insert("id".into(), id);
1667 98 : feature.insert("type".into(), Value::String("Feature".into()));
1668 98 : feature.insert("geometry".into(), geometry);
1669 98 : feature.insert("properties".into(), Value::Object(props));
1670 98 : Value::Object(feature)
1671 98 : }
1672 :
1673 : /// 4.5.16.3 GeoJSON FeatureCollection, members per Table 5.2.30-1 (5.2.30
1674 : /// FeatureCollection): fixed type "FeatureCollection" + features array of
1675 : /// 4.5.16.2 Feature objects — empty array when no matches, no per-Feature
1676 : /// @context; the top-level @context is added by respond() (6.3.6).
1677 40 : pub fn to_geojson_collection(entities: Vec<Value>, geometry_property: Option<&String>) -> Value {
1678 40 : let features: Vec<Value> = entities
1679 40 : .into_iter()
1680 40 : .map(|e| to_geojson_feature(e, geometry_property))
1681 40 : .collect();
1682 40 : serde_json::json!({"type": "FeatureCollection", "features": features})
1683 40 : }
1684 :
1685 : /// The one geometry rule of every GeoJSON this broker writes (NGSI-LD geo+json, and
1686 : /// the `geojson` surface): the GeoProperty `property` (default `location`), its default
1687 : /// instance, `null` unless it holds a valid geometry, rings wound as RFC 7946 3.1.6 asks. A
1688 : /// geometry across the antimeridian is served as stored, not cut (3.1.9 is a SHOULD).
1689 : #[must_use]
1690 106 : pub fn geometry_of(entity: &Value, property: Option<&str>) -> Value {
1691 106 : entity
1692 106 : .get(property.unwrap_or("location"))
1693 106 : .map(geo_value_of)
1694 106 : .map_or(Value::Null, rewound)
1695 106 : }
1696 :
1697 : /// RFC 7946 3.1.6: a Polygon's exterior ring counterclockwise, its holes clockwise, in every
1698 : /// Polygon of a MultiPolygon and a GeometryCollection; other geometries, and a ring across the
1699 : /// antimeridian, as they are.
1700 : #[must_use]
1701 90 : pub fn rewound(mut geometry: Value) -> Value {
1702 : /// Twice the signed planar area, `None` for a ring with an edge across the antimeridian,
1703 : /// whose plane winding is not its winding on the globe.
1704 20 : fn area(ring: &[Value]) -> Option<f64> {
1705 136 : let point = |p: &Value| {
1706 136 : (
1707 136 : p.get(0).and_then(Value::as_f64),
1708 136 : p.get(1).and_then(Value::as_f64),
1709 136 : )
1710 136 : };
1711 20 : let mut sum = 0.0;
1712 68 : for w in ring.windows(2) {
1713 68 : if let (Some((Some(x0), Some(y0))), Some((Some(x1), Some(y1)))) =
1714 68 : (w.first().map(point), w.get(1).map(point))
1715 : {
1716 68 : if (x1 - x0).abs() > 180.0 {
1717 4 : return None;
1718 64 : }
1719 64 : sum += x0 * y1 - x1 * y0;
1720 0 : }
1721 : }
1722 16 : Some(sum)
1723 20 : }
1724 16 : fn polygon(rings: &mut Value) {
1725 20 : for (i, ring) in rings.as_array_mut().into_iter().flatten().enumerate() {
1726 20 : if let Some(points) = ring.as_array_mut() {
1727 20 : if area(points).is_some_and(|a| (a > 0.0) == (i > 0)) {
1728 12 : points.reverse();
1729 12 : }
1730 0 : }
1731 : }
1732 16 : }
1733 90 : match geometry.get("type").and_then(Value::as_str) {
1734 82 : Some("Polygon") => {
1735 12 : if let Some(rings) = geometry.get_mut("coordinates") {
1736 12 : polygon(rings);
1737 12 : }
1738 : }
1739 70 : Some("MultiPolygon") => {
1740 4 : let all = geometry
1741 4 : .get_mut("coordinates")
1742 4 : .and_then(Value::as_array_mut);
1743 4 : all.into_iter().flatten().for_each(polygon);
1744 4 : }
1745 66 : Some("GeometryCollection") => {
1746 0 : if let Some(Value::Array(parts)) = geometry.get_mut("geometries") {
1747 0 : for part in parts.iter_mut() {
1748 0 : *part = rewound(part.take());
1749 0 : }
1750 0 : }
1751 : }
1752 74 : _ => {}
1753 : }
1754 90 : geometry
1755 90 : }
1756 :
1757 : /// 4.5.16.1: with multiple instances the default one (no datasetId) is
1758 : /// selected unless a datasetId filter already narrowed the set to one; a
1759 : /// missing GeoProperty or a value that "does not hold a valid GeoJSON
1760 : /// geometry object" yields null — "which is syntactically valid GeoJSON".
1761 78 : fn geo_value_of(attr: &Value) -> Value {
1762 78 : let inst = match attr {
1763 32 : Value::Array(a) => match a.iter().find(|i| i.get("datasetId").is_none()) {
1764 16 : Some(default) => default.clone(),
1765 0 : None => match a.as_slice() {
1766 0 : [only] => only.clone(),
1767 0 : _ => return Value::Null,
1768 : },
1769 : },
1770 62 : other => other.clone(),
1771 : };
1772 78 : let v = inst.get("value").cloned().unwrap_or(inst);
1773 : // 4.5.17.1: in the simplified representation a multi-instance GeoProperty
1774 : // is the {"dataset": {…}} map — the default ("@none") instance is the
1775 : // 4.5.16.1 selection.
1776 78 : let v = match v.as_object() {
1777 70 : Some(o) if o.len() == 1 && o.contains_key("dataset") => {
1778 8 : o["dataset"].get("@none").cloned().unwrap_or(Value::Null)
1779 : }
1780 70 : _ => v,
1781 : };
1782 78 : match antares_jsonld::expand::validate_geojson("geometry", &v) {
1783 70 : Ok(()) => v,
1784 8 : Err(_) => Value::Null,
1785 : }
1786 78 : }
1787 :
1788 : #[cfg(test)]
1789 : mod tests {
1790 : use super::*;
1791 : use serde_json::json;
1792 :
1793 : /// 4.5.4 Examples 1–16: the simplified value of one instance per
1794 : /// attribute type — bare for Property/GeoProperty/Relationship/List*,
1795 : /// wrapped single-key objects for Language/Json/Vocab subtypes.
1796 : #[test]
1797 4 : fn simplified_values_per_attribute_type() {
1798 28 : let v = |j: Value| simplified_value(j.as_object().unwrap());
1799 4 : assert_eq!(v(json!({"type": "Property", "value": 5})), json!(5));
1800 4 : assert_eq!(
1801 4 : v(json!({"type": "Relationship", "object": "urn:a"})),
1802 4 : json!("urn:a")
1803 : );
1804 4 : assert_eq!(
1805 4 : v(json!({"type": "ListProperty", "valueList": [1, 2]})),
1806 4 : json!([1, 2])
1807 : );
1808 4 : assert_eq!(
1809 4 : v(json!({"type": "ListRelationship", "objectList": ["urn:a"]})),
1810 4 : json!(["urn:a"])
1811 : );
1812 4 : assert_eq!(
1813 4 : v(json!({"type": "LanguageProperty", "languageMap": {"en": "hi"}})),
1814 4 : json!({"languageMap": {"en": "hi"}})
1815 : );
1816 4 : assert_eq!(
1817 4 : v(json!({"type": "JsonProperty", "json": {"k": 1}})),
1818 4 : json!({"json": {"k": 1}})
1819 : );
1820 4 : assert_eq!(
1821 4 : v(json!({"type": "VocabProperty", "vocab": "V"})),
1822 4 : json!({"vocab": "V"})
1823 : );
1824 4 : }
1825 : }
1826 :
1827 : #[cfg(test)]
1828 : mod clause_4_15 {
1829 : use super::*;
1830 : use serde_json::json;
1831 :
1832 36 : fn lm(pairs: &[(&str, &str)]) -> Map<String, Value> {
1833 36 : pairs
1834 36 : .iter()
1835 60 : .map(|(k, v)| ((*k).to_owned(), json!(*v)))
1836 36 : .collect()
1837 36 : }
1838 :
1839 : /// 4.15 EXAMPLE 4: quality value ranking — entries are ordered by their
1840 : /// q weight (default 1), not by list position.
1841 : #[test]
1842 4 : fn q_values_rank_the_priority_list() {
1843 4 : let m = lm(&[("en", "red"), ("fr", "rouge")]);
1844 4 : let (l, v) = select_lang(&m, "en;q=0.2,fr;q=0.9").expect("pick");
1845 4 : assert_eq!((l.as_str(), &v), ("fr", &json!("rouge")), "fr outranks en");
1846 : // default q=1: plain fr-CH beats fr;q=0.9
1847 4 : let m = lm(&[("fr-CH", "rouge suisse"), ("fr", "rouge")]);
1848 4 : let (l, _) = select_lang(&m, "fr-CH,fr;q=0.9").expect("pick");
1849 4 : assert_eq!(l, "fr-CH");
1850 : // wildcard with low q still yields a fallback when nothing else fits
1851 4 : let m = lm(&[("de", "rot")]);
1852 4 : let (l, _) = select_lang(&m, "fr;q=0.9,*;q=0.5").expect("pick");
1853 4 : assert_eq!(l, "de");
1854 4 : }
1855 :
1856 : /// RFC 5646 (via 4.15): language tags compare case-insensitively.
1857 : #[test]
1858 4 : fn langtags_compare_case_insensitively() {
1859 4 : let m = lm(&[("en-US", "color")]);
1860 4 : let (l, v) = select_lang(&m, "en-us").expect("pick");
1861 4 : assert_eq!((l.as_str(), &v), ("en-US", &json!("color")));
1862 4 : let m = lm(&[("fr", "rouge"), ("de", "rot")]);
1863 4 : let (l, _) = select_lang(&m, "FR").expect("pick");
1864 4 : assert_eq!(l, "fr");
1865 4 : }
1866 :
1867 : /// RFC 5646 lookup (via 4.15): a shorter range matches a longer tag
1868 : /// (lang=fr picks fr-CH) and a longer range truncates onto a shorter tag
1869 : /// (lang=fr-CH picks fr) — the `lang` subproperty reports the ACTUAL tag.
1870 : #[test]
1871 4 : fn prefix_and_truncation_fallbacks() {
1872 : // decoy `de` sorts first — a naive any-fallback would pick it
1873 4 : let m = lm(&[("de", "rot"), ("fr-CH", "rouge suisse")]);
1874 4 : let (l, _) = select_lang(&m, "fr").expect("pick");
1875 4 : assert_eq!(l, "fr-CH", "range fr matches tag fr-CH, not the decoy");
1876 4 : let m = lm(&[("de", "rot"), ("fr", "rouge")]);
1877 4 : let (l, _) = select_lang(&m, "fr-CH").expect("pick");
1878 4 : assert_eq!(l, "fr", "range fr-CH truncates onto tag fr, not the decoy");
1879 : // an exact match still beats a prefix match at the same rank
1880 4 : let m = lm(&[("fr-CH", "suisse"), ("fr", "rouge")]);
1881 4 : let (l, _) = select_lang(&m, "fr").expect("pick");
1882 4 : assert_eq!(l, "fr");
1883 4 : }
1884 :
1885 : /// 4.15: "If the Context Broker cannot serve any matching language, it
1886 : /// shall default to any supported language" — and the augmented `lang`
1887 : /// subproperty carries the actually returned one.
1888 : #[test]
1889 4 : fn no_match_falls_back_to_any_supported_language() {
1890 4 : let m = lm(&[("de", "rot")]);
1891 4 : let (l, v) = select_lang(&m, "pt").expect("fallback");
1892 4 : assert_eq!((l.as_str(), &v), ("de", &json!("rot")));
1893 : // the transform augments with the actual language and converts the
1894 : // LanguageProperty to a Property — languageMap must NOT survive
1895 4 : let inst = json!({"type": "LanguageProperty",
1896 4 : "languageMap": {"en": "red", "fr": "rouge"}});
1897 4 : let r = Repr {
1898 4 : lang: Some("fr".into()),
1899 4 : ..Repr::default()
1900 4 : };
1901 4 : let out = transform_instance(&inst, &r);
1902 4 : assert_eq!(out["type"], "Property");
1903 4 : assert_eq!(out["value"], "rouge");
1904 4 : assert_eq!(out["lang"], "fr");
1905 4 : assert!(
1906 4 : out.get("languageMap").is_none(),
1907 : "languageMap must not remain after conversion"
1908 : );
1909 4 : }
1910 : }
1911 :
1912 : #[cfg(test)]
1913 : mod clause_4_21 {
1914 : use super::*;
1915 : use antares_jsonld::Loader;
1916 :
1917 : /// 4.21: "either a comma or a pipe character can be used as alternative
1918 : /// representations of the or operator" — including inside a nested
1919 : /// LinkedEntityTerm (EXAMPLE 3).
1920 : #[test]
1921 4 : fn pipe_and_comma_are_both_or_operators() {
1922 4 : let ctx = Loader::new().core();
1923 4 : let comma = parse_projection("temperature,humidity", &ctx).expect("comma");
1924 4 : let pipe = parse_projection("temperature|humidity", &ctx).expect("pipe");
1925 4 : assert_eq!(comma.len(), 2);
1926 4 : assert_eq!(pipe.len(), 2);
1927 4 : assert_eq!(comma[0].raw, pipe[0].raw);
1928 4 : assert_eq!(comma[1].raw, pipe[1].raw);
1929 4 : let nested = parse_projection("observation{temperature|humidity}", &ctx).expect("nested");
1930 4 : assert_eq!(nested.len(), 1);
1931 4 : let kids = nested[0].children.as_ref().expect("children");
1932 4 : assert_eq!(kids.len(), 2, "pipe splits inside the braces too");
1933 4 : }
1934 :
1935 : /// 4.21 grammar: an empty member or unbalanced braces are violations.
1936 : #[test]
1937 4 : fn grammar_rejections_hold_for_both_spellings() {
1938 4 : let ctx = Loader::new().core();
1939 20 : for bad in ["a||b", "a|,b", "|a", "a|", "a{b|}"] {
1940 20 : assert!(
1941 20 : parse_projection(bad, &ctx).is_err(),
1942 : "{bad:?} must be rejected"
1943 : );
1944 : }
1945 4 : }
1946 :
1947 : /// Each `{…}` level of a projection is one Linked Entity hop (5.7.1.4),
1948 : /// so a selection deeper than the joinLevel ceiling can never be
1949 : /// satisfied. The depth is bounded BEFORE the recursive descent parses
1950 : /// it: pick= arrives as a plain string inside a query body, where the
1951 : /// JSON nesting wall never sees its braces.
1952 : #[test]
1953 4 : fn projection_nesting_is_bounded_before_the_parser_recurses() {
1954 4 : let ctx = Loader::new().core();
1955 4 : let cap = crate::bounds::MAX_JOIN_LEVEL;
1956 12 : let nested = |n: usize| "a{".repeat(n) + "b" + &"}".repeat(n);
1957 : // the body path can carry a projection far past any stack budget
1958 4 : assert!(
1959 4 : parse_projection(&nested(200_000), &ctx).is_err(),
1960 : "a body-sized projection must be rejected, not recursed into"
1961 : );
1962 4 : assert!(parse_projection(&nested(cap), &ctx).is_ok(), "at the cap");
1963 4 : assert!(
1964 4 : parse_projection(&nested(cap + 1), &ctx).is_err(),
1965 : "one level over the cap must be rejected"
1966 : );
1967 4 : }
1968 :
1969 : /// 5.7.1.4: proj_depth reports the hops a projection implies, so the
1970 : /// joinLevel comparison upstream is made against the deepest branch.
1971 : #[test]
1972 4 : fn projection_depth_counts_the_deepest_branch() {
1973 4 : let ctx = Loader::new().core();
1974 12 : let d = |s: &str| proj_depth(&parse_projection(s, &ctx).expect("parse"));
1975 4 : assert_eq!(d("a,b"), 0, "a flat selection implies no hop");
1976 4 : assert_eq!(d("a{b}"), 1);
1977 4 : assert_eq!(d("a,b{c{d}}"), 2, "the deepest branch wins");
1978 4 : }
1979 : }
1980 :
1981 : #[cfg(test)]
1982 : mod clause_6_3_7 {
1983 : use super::*;
1984 : use antares_jsonld::Loader;
1985 : use serde_json::json;
1986 :
1987 104 : fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1988 104 : pairs
1989 104 : .iter()
1990 132 : .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1991 104 : .collect()
1992 104 : }
1993 :
1994 : /// 6.3.7: an unknown options or format value is not silently ignored.
1995 : #[test]
1996 4 : fn unknown_options_and_format_values_are_rejected() {
1997 4 : let ctx = Loader::new().core();
1998 20 : for p in [
1999 4 : params(&[("options", "sysattrs")]),
2000 4 : params(&[("options", "keyValues,bogus")]),
2001 4 : params(&[("options", "")]),
2002 4 : params(&[("format", "verbose")]),
2003 4 : params(&[("format", "KeyValues")]),
2004 4 : ] {
2005 20 : let e = parse_repr(&p, &ctx).expect_err("must be rejected");
2006 20 : assert!(
2007 20 : matches!(e, NgsiError::InvalidRequest(_)),
2008 : "unsupported representation value is InvalidRequest, got {e:?}"
2009 : );
2010 : }
2011 4 : }
2012 :
2013 : /// 6.3.7: format wins over options when the two disagree, and keyValues
2014 : /// is the older spelling of simplified.
2015 : #[test]
2016 4 : fn format_wins_over_options_on_conflict() {
2017 4 : let ctx = Loader::new().core();
2018 4 : let r = parse_repr(
2019 4 : ¶ms(&[("options", "concise"), ("format", "simplified")]),
2020 4 : &ctx,
2021 : )
2022 4 : .expect("parse");
2023 4 : assert!(r.key_values, "format=simplified wins");
2024 4 : assert!(!r.concise, "options=concise must NOT survive the conflict");
2025 4 : let r = parse_repr(
2026 4 : ¶ms(&[("options", "keyValues"), ("format", "normalized")]),
2027 4 : &ctx,
2028 : )
2029 4 : .expect("parse");
2030 4 : assert!(!r.key_values && !r.concise, "normalized is neither");
2031 4 : let r = parse_repr(¶ms(&[("options", "sysAttrs,keyValues")]), &ctx).expect("parse");
2032 4 : assert!(r.sys_attrs && r.key_values);
2033 4 : }
2034 :
2035 : /// Table 6.4.3.2-1 datasetId: "Shall be valid URIs, "@none" for including
2036 : /// the default Attribute instances" (Coraine url_param_datasetid_validation).
2037 : #[test]
2038 4 : fn a_dataset_id_parameter_is_a_uri_or_none() {
2039 4 : let ctx = Loader::new().core();
2040 12 : for bad in ["14", "urn:ds:1,14", ""] {
2041 12 : let e = parse_repr(¶ms(&[("datasetId", bad)]), &ctx).expect_err(bad);
2042 12 : assert!(matches!(e, NgsiError::BadRequestData(_)), "{bad}: {e:?}");
2043 : }
2044 4 : assert!(parse_repr(¶ms(&[("datasetId", "urn:ds:1,@none")]), &ctx).is_ok());
2045 4 : }
2046 :
2047 : /// pick, omit and attrs: any pair is a 400 (see check_projection_exclusive),
2048 : /// each one alone is fine.
2049 : #[test]
2050 4 : fn pick_omit_and_attrs_cannot_be_combined() {
2051 4 : let ctx = Loader::new().core();
2052 16 : for p in [
2053 4 : params(&[("pick", "a"), ("omit", "b")]),
2054 4 : params(&[("pick", "a"), ("attrs", "b")]),
2055 4 : params(&[("omit", "a"), ("attrs", "b")]),
2056 4 : params(&[("pick", "a"), ("omit", "b"), ("attrs", "c")]),
2057 4 : ] {
2058 16 : let e = parse_repr(&p, &ctx).expect_err("must be rejected");
2059 16 : assert!(matches!(e, NgsiError::BadRequestData(_)), "got {e:?}");
2060 : }
2061 4 : assert!(parse_repr(¶ms(&[("pick", "a")]), &ctx).is_ok());
2062 4 : assert!(parse_repr(¶ms(&[("attrs", "a")]), &ctx).is_ok());
2063 4 : }
2064 :
2065 : /// attrs= selects ATTRIBUTES: an entity meta member or @context is not an
2066 : /// attribute name, and an empty member is a grammar violation.
2067 : #[test]
2068 4 : fn attrs_rejects_entity_members_and_empty_names() {
2069 4 : let ctx = Loader::new().core();
2070 28 : for bad in ["id", "type", "scope", "createdAt", "@context", "a,,b", ""] {
2071 28 : assert!(
2072 28 : parse_repr(¶ms(&[("attrs", bad)]), &ctx).is_err(),
2073 : "attrs={bad:?} must be rejected"
2074 : );
2075 : }
2076 4 : let r = parse_repr(¶ms(&[("attrs", "temperature, humidity")]), &ctx).expect("parse");
2077 4 : let list = r.attrs.expect("attrs");
2078 4 : assert_eq!(list.len(), 2);
2079 8 : assert!(list.iter().all(|a| a.contains("://")), "names are expanded");
2080 4 : }
2081 :
2082 36 : fn entity() -> Value {
2083 36 : json!({
2084 36 : "id": "urn:ngsi-ld:E:1",
2085 36 : "type": "T",
2086 36 : "createdAt": "2026-01-01T00:00:00Z",
2087 36 : "modifiedAt": "2026-01-02T00:00:00Z",
2088 36 : "https://example.org/temperature": [
2089 36 : {"type": "Property", "value": 21,
2090 36 : "createdAt": "2026-01-01T00:00:00Z",
2091 36 : "https://example.org/accuracy": [{"type": "Property", "value": 0.5}]},
2092 36 : {"type": "Property", "value": 9, "datasetId": "urn:ds:2"}
2093 : ]
2094 : })
2095 36 : }
2096 :
2097 : /// 6.3.11 Table 6.3.11-1: createdAt/modifiedAt are system attributes —
2098 : /// they must be absent from the default representation, on the entity AND
2099 : /// on every attribute instance, and present with options=sysAttrs.
2100 : #[test]
2101 4 : fn system_attributes_stay_hidden_without_sysattrs() {
2102 4 : let plain = apply(&entity(), &Repr::default());
2103 4 : assert!(plain.get("createdAt").is_none(), "entity createdAt leaked");
2104 4 : assert!(
2105 4 : plain.get("modifiedAt").is_none(),
2106 : "entity modifiedAt leaked"
2107 : );
2108 4 : assert!(
2109 4 : plain["https://example.org/temperature"][0]
2110 4 : .get("createdAt")
2111 4 : .is_none(),
2112 : "instance createdAt leaked"
2113 : );
2114 4 : assert_eq!(plain["id"], "urn:ngsi-ld:E:1");
2115 4 : let sys = apply(
2116 4 : &entity(),
2117 4 : &Repr {
2118 4 : sys_attrs: true,
2119 4 : ..Repr::default()
2120 4 : },
2121 : );
2122 4 : assert_eq!(sys["createdAt"], "2026-01-01T00:00:00Z");
2123 4 : assert_eq!(
2124 4 : sys["https://example.org/temperature"][0]["createdAt"],
2125 : "2026-01-01T00:00:00Z"
2126 : );
2127 4 : }
2128 :
2129 : /// 4.5.4 concise: the type member is dropped and an instance left with a
2130 : /// lone value collapses to that bare value — sub-attributes included.
2131 : #[test]
2132 4 : fn concise_drops_type_and_collapses_bare_values() {
2133 4 : let out = apply(
2134 4 : &entity(),
2135 4 : &Repr {
2136 4 : concise: true,
2137 4 : ..Repr::default()
2138 4 : },
2139 : );
2140 4 : let inst = &out["https://example.org/temperature"][0];
2141 4 : assert!(inst.get("type").is_none(), "concise keeps no type member");
2142 4 : assert_eq!(
2143 4 : inst["https://example.org/accuracy"],
2144 4 : json!([0.5]),
2145 : "a value-only sub-attribute collapses to the bare value"
2146 : );
2147 : // an instance carrying more than value keeps the object form
2148 4 : let d = apply(
2149 4 : &json!({"https://example.org/a": [{"type": "Property", "value": 1, "unitCode": "CEL"}]}),
2150 4 : &Repr {
2151 4 : concise: true,
2152 4 : ..Repr::default()
2153 4 : },
2154 : );
2155 4 : assert_eq!(d["https://example.org/a"][0]["unitCode"], "CEL");
2156 4 : assert_eq!(d["https://example.org/a"][0]["value"], 1);
2157 4 : }
2158 :
2159 : /// 4.5.4: with several instances the simplified form is a dataset map
2160 : /// keyed by datasetId, "@none" standing for the default instance — a bare
2161 : /// array of values would lose which instance is which. The map carries one
2162 : /// pair for each datasetId, so a dropped instance is a dropped datasetId.
2163 : #[test]
2164 4 : fn simplified_multi_instance_uses_the_dataset_map() {
2165 4 : let out = apply(
2166 4 : &entity(),
2167 4 : &Repr {
2168 4 : key_values: true,
2169 4 : ..Repr::default()
2170 4 : },
2171 : );
2172 4 : let t = &out["https://example.org/temperature"];
2173 4 : assert!(t.get("dataset").is_some(), "multi-instance needs the map");
2174 4 : assert_eq!(t["dataset"]["@none"], 21);
2175 4 : assert_eq!(t["dataset"]["urn:ds:2"], 9);
2176 4 : assert!(t.as_array().is_none(), "not a bare array");
2177 : // one instance stays a bare value
2178 4 : let one = apply(
2179 4 : &json!({"https://example.org/a": [{"type": "Property", "value": 1}]}),
2180 4 : &Repr {
2181 4 : key_values: true,
2182 4 : ..Repr::default()
2183 4 : },
2184 : );
2185 4 : assert_eq!(one["https://example.org/a"], json!(1));
2186 : // 4.5.4 EXAMPLE 2 is three instances; the map is built by pairing
2187 : // instances with the rows they came from, so an instance lost on the
2188 : // way leaves a map one pair short rather than an error
2189 4 : let three = apply(
2190 4 : &json!({"https://example.org/name": [
2191 4 : {"type": "Property", "value": "David Robert Jones"},
2192 4 : {"type": "Property", "value": "David Bowie",
2193 4 : "datasetId": "urn:ngsi-ld:datasetId:001"},
2194 4 : {"type": "Property", "value": "Ziggy Stardust",
2195 4 : "datasetId": "urn:ngsi-ld:datasetId:002"}
2196 4 : ]}),
2197 4 : &Repr {
2198 4 : key_values: true,
2199 4 : ..Repr::default()
2200 4 : },
2201 : );
2202 4 : let ds = three["https://example.org/name"]["dataset"]
2203 4 : .as_object()
2204 4 : .expect("dataset map");
2205 4 : assert_eq!(ds.len(), 3, "one pair for each datasetId");
2206 4 : assert_eq!(ds["@none"], "David Robert Jones");
2207 4 : assert_eq!(ds["urn:ngsi-ld:datasetId:001"], "David Bowie");
2208 4 : assert_eq!(ds["urn:ngsi-ld:datasetId:002"], "Ziggy Stardust");
2209 4 : }
2210 :
2211 : /// datasetId= selects instances; "@none" selects the default one. An
2212 : /// attribute left with no surviving instance is absent, not empty.
2213 : #[test]
2214 4 : fn dataset_id_filter_selects_instances_and_drops_empty_attributes() {
2215 12 : let sel = |ids: &[&str]| {
2216 12 : apply(
2217 12 : &entity(),
2218 : &Repr {
2219 12 : dataset_id: Some(ids.iter().map(|s| (*s).to_owned()).collect()),
2220 12 : ..Repr::default()
2221 : },
2222 : )
2223 12 : };
2224 4 : let out = sel(&["urn:ds:2"]);
2225 4 : let insts = out["https://example.org/temperature"]
2226 4 : .as_array()
2227 4 : .expect("array");
2228 4 : assert_eq!(insts.len(), 1);
2229 4 : assert_eq!(insts[0]["value"], 9, "the default instance must be gone");
2230 4 : let out = sel(&["@none"]);
2231 4 : let insts = out["https://example.org/temperature"]
2232 4 : .as_array()
2233 4 : .expect("array");
2234 4 : assert_eq!(insts.len(), 1);
2235 4 : assert_eq!(insts[0]["value"], 21);
2236 4 : let out = sel(&["urn:ds:absent"]);
2237 4 : assert!(
2238 4 : out.get("https://example.org/temperature").is_none(),
2239 : "an attribute with no matching instance is omitted entirely"
2240 : );
2241 4 : assert_eq!(out["id"], "urn:ngsi-ld:E:1", "entity members survive");
2242 4 : }
2243 :
2244 : /// The reserved members of an attribute instance carry values, not
2245 : /// sub-attributes: an array-valued `value` (or objectList, previousValue…)
2246 : /// must be passed through untouched, never walked as a list of instances.
2247 : #[test]
2248 4 : fn array_valued_reserved_members_are_not_sub_attributes() {
2249 4 : let doc = json!({"https://example.org/a": [{
2250 4 : "type": "ListProperty",
2251 4 : "valueList": [1, 2, 3],
2252 4 : "value": [{"type": "Property", "value": 7}],
2253 4 : "previousValue": [9],
2254 4 : "https://example.org/note": [{"type": "Property", "value": "sub"}]
2255 : }]});
2256 4 : let out = apply(&doc, &Repr::default());
2257 4 : let inst = &out["https://example.org/a"][0];
2258 4 : assert_eq!(inst["valueList"], json!([1, 2, 3]));
2259 4 : assert_eq!(
2260 4 : inst["value"],
2261 4 : json!([{"type": "Property", "value": 7}]),
2262 : "an array value is data, not an instance list to transform"
2263 : );
2264 4 : assert_eq!(inst["previousValue"], json!([9]));
2265 : // a genuine sub-attribute IS walked
2266 4 : assert_eq!(inst["https://example.org/note"][0]["value"], "sub");
2267 : // …and the walk applies the representation to it
2268 4 : let sys = apply(
2269 4 : &json!({"https://example.org/a": [{"type": "Property", "value": 1,
2270 4 : "https://example.org/note": [{"type": "Property", "value": "s",
2271 4 : "modifiedAt": "2026-01-01T00:00:00Z"}]}]}),
2272 4 : &Repr::default(),
2273 : );
2274 4 : assert!(
2275 4 : sys["https://example.org/a"][0]["https://example.org/note"][0]
2276 4 : .get("modifiedAt")
2277 4 : .is_none(),
2278 : "the sysAttrs gate reaches sub-attributes"
2279 : );
2280 4 : }
2281 :
2282 : /// 4.21: pick constrains core members too — an entity member not picked
2283 : /// does not survive; omit only drops the heads it names outright.
2284 : #[test]
2285 4 : fn pick_is_strict_over_core_members_and_omit_is_not() {
2286 4 : let ctx = Loader::new().core();
2287 4 : let picked = apply(
2288 4 : &entity(),
2289 4 : &Repr {
2290 4 : pick: Some(parse_projection("id", &ctx).expect("pick")),
2291 4 : ..Repr::default()
2292 4 : },
2293 : );
2294 4 : assert_eq!(picked["id"], "urn:ngsi-ld:E:1");
2295 4 : assert!(picked.get("type").is_none(), "type was not picked");
2296 4 : assert!(
2297 4 : picked.get("https://example.org/temperature").is_none(),
2298 : "an unpicked attribute must not survive"
2299 : );
2300 4 : let omitted = apply(
2301 4 : &entity(),
2302 4 : &Repr {
2303 4 : omit: Some(parse_projection("scope", &ctx).expect("omit")),
2304 4 : ..Repr::default()
2305 4 : },
2306 : );
2307 4 : assert_eq!(omitted["type"], "T", "omit leaves the rest of the entity");
2308 4 : }
2309 :
2310 : /// 4.5.16.1/4.5.16.2/4.5.16.3: geometry selection (default instance,
2311 : /// datasetId-narrowed single, invalid value -> null) and the
2312 : /// RFC 7946 3.1.6 on the way out: a clockwise exterior turns counterclockwise, a
2313 : /// counterclockwise hole clockwise; a geometry across the antimeridian stays as stored.
2314 : #[test]
2315 4 : fn geojson_rings_are_wound_and_the_antimeridian_is_not_cut() {
2316 4 : let cw = json!([[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]);
2317 4 : let ccw = json!([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]);
2318 4 : let hole_ccw = json!([[0.2, 0.2], [0.8, 0.2], [0.8, 0.8], [0.2, 0.8], [0.2, 0.2]]);
2319 4 : let hole_cw = json!([[0.2, 0.2], [0.2, 0.8], [0.8, 0.8], [0.8, 0.2], [0.2, 0.2]]);
2320 20 : let polygon = |rings: Value| json!({"type": "Polygon", "coordinates": rings});
2321 4 : assert_eq!(
2322 4 : rewound(polygon(json!([cw, hole_ccw]))),
2323 4 : polygon(json!([ccw, hole_cw]))
2324 : );
2325 4 : assert_eq!(rewound(polygon(json!([ccw]))), polygon(json!([ccw])));
2326 4 : let multi = json!({"type": "MultiPolygon", "coordinates": [[cw]]});
2327 4 : assert_eq!(rewound(multi)["coordinates"], json!([[ccw]]));
2328 4 : let across = polygon(json!([[
2329 4 : [179, 0],
2330 4 : [-179, 0],
2331 4 : [-179, 1],
2332 4 : [179, 1],
2333 4 : [179, 0]
2334 4 : ]]));
2335 4 : let entity = json!({"location": {"type": "GeoProperty", "value": across}});
2336 4 : assert_eq!(geometry_of(&entity, None), across);
2337 : // the default instance, not the first, of a multi-instance GeoProperty
2338 4 : let two = json!({"place": [
2339 4 : {"type": "GeoProperty", "datasetId": "urn:a", "value": {"type": "Point", "coordinates": [1, 1]}},
2340 4 : {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [2, 2]}}]});
2341 4 : assert_eq!(
2342 4 : geometry_of(&two, Some("place"))["coordinates"],
2343 4 : json!([2, 2])
2344 : );
2345 4 : }
2346 :
2347 : /// Feature/FeatureCollection shapes.
2348 : #[test]
2349 4 : fn geojson_feature_selection_and_shape() {
2350 : use super::{to_geojson_collection, to_geojson_feature};
2351 : use serde_json::Value;
2352 4 : let entity = json!({
2353 4 : "id": "urn:ngsi-ld:V:1", "type": "Vehicle",
2354 4 : "location": [
2355 4 : {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [9.0, 9.0]},
2356 4 : "datasetId": "urn:ngsi-ld:Dataset:gps"},
2357 4 : {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [1.0, 2.0]}}
2358 : ],
2359 4 : "speed": {"type": "Property", "value": 5}
2360 : });
2361 4 : let f = to_geojson_feature(entity.clone(), None);
2362 4 : assert_eq!(f["type"], "Feature");
2363 4 : assert_eq!(f["id"], "urn:ngsi-ld:V:1");
2364 : // default instance (no datasetId) wins over the first array element
2365 4 : assert_eq!(
2366 4 : f["geometry"],
2367 4 : json!({"type": "Point", "coordinates": [1.0, 2.0]})
2368 : );
2369 4 : assert_eq!(f["properties"]["type"], "Vehicle");
2370 4 : assert!(
2371 4 : f["properties"].get("id").is_none(),
2372 : "id only at Feature level"
2373 : );
2374 4 : assert!(f["properties"].get("speed").is_some());
2375 :
2376 : // geometryProperty naming a non-geometry Property -> null geometry
2377 4 : let f2 = to_geojson_feature(entity.clone(), Some(&"speed".to_string()));
2378 4 : assert_eq!(f2["geometry"], Value::Null);
2379 : // absent GeoProperty -> null geometry
2380 4 : let f3 = to_geojson_feature(entity.clone(), Some(&"missing".to_string()));
2381 4 : assert_eq!(f3["geometry"], Value::Null);
2382 :
2383 : // 4.5.17.1: simplified multi-instance GeoProperty = dataset map;
2384 : // the "@none" (default) entry is the geometry
2385 4 : let simplified = json!({
2386 4 : "id": "urn:ngsi-ld:V:2", "type": "Vehicle",
2387 4 : "location": {"dataset": {
2388 4 : "urn:ngsi-ld:Dataset:gps": {"type": "Point", "coordinates": [9.0, 9.0]},
2389 4 : "@none": {"type": "Point", "coordinates": [3.0, 4.0]}
2390 : }},
2391 4 : "speed": 5
2392 : });
2393 4 : let fs = to_geojson_feature(simplified, None);
2394 4 : assert_eq!(
2395 4 : fs["geometry"],
2396 4 : json!({"type": "Point", "coordinates": [3.0, 4.0]})
2397 : );
2398 4 : assert_eq!(fs["properties"]["speed"], 5);
2399 :
2400 4 : let fc = to_geojson_collection(vec![entity], None);
2401 4 : assert_eq!(fc["type"], "FeatureCollection");
2402 4 : assert_eq!(fc["features"].as_array().map(Vec::len), Some(1));
2403 4 : assert!(
2404 4 : fc["features"][0].get("@context").is_none(),
2405 : "no per-Feature @context"
2406 : );
2407 : // Table 5.2.30-1: "In the case that no matches are found, features
2408 : // will be an empty array"
2409 4 : let empty = to_geojson_collection(vec![], None);
2410 4 : assert_eq!(empty["type"], "FeatureCollection");
2411 4 : assert_eq!(empty["features"], json!([]));
2412 4 : }
2413 : }
2414 :
2415 : #[cfg(test)]
2416 : mod type_rules_memo_tests {
2417 : use super::type_rules;
2418 : use crate::policy::{Filter, Rule};
2419 : use std::collections::BTreeMap;
2420 :
2421 20 : fn filter(t: &str, attr: &str) -> Filter {
2422 20 : Filter {
2423 20 : rules: vec![Rule {
2424 20 : types: vec![t.to_owned()],
2425 20 : attributes: vec![attr.to_owned()],
2426 20 : ..Rule::default()
2427 20 : }],
2428 20 : ..Filter::default()
2429 20 : }
2430 20 : }
2431 :
2432 : const DEFAULT: &str = "https://uri.etsi.org/ngsi-ld/default-context/";
2433 :
2434 : /// The memo answers only for the rules it was made from: alternating
2435 : /// filters (two views, two subjects) each get their own expansion.
2436 : #[test]
2437 4 : fn the_memo_answers_only_for_its_own_rules() {
2438 4 : let (car, user) = (filter("Car", "speed"), filter("User", "name"));
2439 20 : for f in [&car, &user, &car, &car, &user] {
2440 20 : let got = type_rules(f).unwrap();
2441 20 : let r = &f.rules[0];
2442 20 : assert_eq!(got.rules[0].types, vec![format!("{DEFAULT}{}", r.types[0])]);
2443 20 : assert_eq!(
2444 20 : got.rules[0].attributes,
2445 20 : vec![format!("{DEFAULT}{}", r.attributes[0])]
2446 : );
2447 : }
2448 4 : let omitting = Filter {
2449 4 : omit_by_type: BTreeMap::from([("Car".to_owned(), vec!["speed".to_owned()])]),
2450 4 : ..car.clone()
2451 4 : };
2452 4 : let got = type_rules(&omitting).unwrap();
2453 4 : assert_eq!(
2454 4 : got.omit_by_type.get(&format!("{DEFAULT}Car")),
2455 4 : Some(&vec![format!("{DEFAULT}speed")])
2456 : );
2457 4 : assert!(type_rules(&car).unwrap().omit_by_type.is_empty());
2458 4 : assert!(type_rules(&Filter::default()).is_none());
2459 4 : }
2460 :
2461 : /// the reach memo answers for the one `Arc` it holds. The same
2462 : /// `Arc` gets the same reach back; another `Arc`, even one with equal
2463 : /// content, or a narrowing that was dropped and rebuilt, gets its own,
2464 : /// so a reach never outlives the Filter it was made from.
2465 : #[test]
2466 4 : fn the_reach_memo_answers_only_for_its_own_arc() {
2467 : use std::sync::Arc;
2468 32 : let reach = |f: &Arc<Filter>| {
2469 32 : let mut r = super::Repr::default();
2470 32 : super::narrow_repr(&mut r, f);
2471 32 : r
2472 32 : };
2473 4 : let car = Arc::new(filter("Car", "speed"));
2474 4 : let user = Arc::new(filter("User", "name"));
2475 4 : let (a, b) = (reach(&car), reach(&car));
2476 4 : let (ra, rb) = (a.policy_reach.unwrap(), b.policy_reach.unwrap());
2477 4 : assert!(Arc::ptr_eq(&ra, &rb), "the same Arc reuses its reach");
2478 12 : for (f, t) in [(&user, "User"), (&car, "Car"), (&user, "User")] {
2479 12 : let got = reach(f).policy_reach.unwrap();
2480 12 : let rules = got.rules.as_ref().unwrap();
2481 12 : assert_eq!(rules.rules[0].types, vec![format!("{DEFAULT}{t}")]);
2482 : }
2483 4 : let twin = Arc::new(filter("Car", "speed"));
2484 4 : let own = reach(&twin).policy_reach.unwrap();
2485 4 : assert!(
2486 4 : !Arc::ptr_eq(&own, &ra),
2487 : "an equal Filter in another Arc builds its own"
2488 : );
2489 4 : assert_eq!(own.rules, ra.rules);
2490 4 : let narrowed = Arc::new(Filter {
2491 4 : q: Some(Box::new(antares_ql::parse_q("speed>1").expect("q"))),
2492 4 : ..Filter::default()
2493 4 : });
2494 4 : assert!(reach(&narrowed).policy_reach.unwrap().q.is_some());
2495 4 : let open = crate::policy::unnarrowed();
2496 4 : let none = reach(&open);
2497 4 : assert!(none.policy_reach.is_none() && none.policy_types.is_none());
2498 4 : assert!(super::narrowing(&car).policy_reach.unwrap().rules.is_some());
2499 4 : }
2500 : }
2501 :
2502 : #[cfg(test)]
2503 : mod t3932_projection_edges {
2504 : use super::*;
2505 : use antares_jsonld::Loader;
2506 :
2507 : /// the projection parser splits at the brace it found and counts
2508 : /// depth without overflow; a malformed or multi-byte term is a 400, never
2509 : /// a panic.
2510 : #[test]
2511 4 : fn malformed_and_multibyte_projections_are_refused() {
2512 4 : let ctx = Loader::new().core();
2513 4 : let deep = parse_projection("a{b{c}}", &ctx).expect("nested");
2514 4 : assert_eq!(proj_depth(&deep), 2);
2515 4 : assert_eq!(deep[0].raw, "a");
2516 36 : for bad in ["a{b", "a}", "a{}", "{", "}", ",", "a,,b", "é{ü|ö}", "a{é}"] {
2517 36 : assert!(parse_projection(bad, &ctx).is_err(), "{bad} is refused");
2518 : }
2519 4 : }
2520 : }
|