Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! /temporal/entities (5.6.11–5.6.16, 5.7.3/5.7.4; resources 6.18–6.22).
3 :
4 : use crate::negotiate::*;
5 : use crate::state::{now_iso, AppState};
6 : use crate::temporalq::TemporalQ;
7 : use antares_jsonld::compact::compact_instance;
8 : use antares_jsonld::{expand_entity, Context, ExpandOpts};
9 : use antares_model::{dt_key, NgsiError, TenantId};
10 : use antares_ql::parse_q;
11 : use antares_store::TemporalDriverExt as _;
12 : use axum::body::Bytes;
13 : use axum::extract::{Path, State};
14 : use axum::http::{HeaderMap, StatusCode};
15 : use axum::response::{IntoResponse, Response};
16 : use serde_json::{json, Map, Value};
17 : use std::collections::HashMap;
18 :
19 : use crate::negotiate::CleanParams;
20 :
21 : use antares_model::is_meta;
22 :
23 : /// 5.6.11 input: the pushed Temporal Evolution may carry the 4.5.7
24 : /// deleted-instance representation (value = NGSI-LD Null), which 5.5.4
25 : /// explicitly excepts for "the temporal evolution" — hence allow_null.
26 : const TEMPORAL_OPTS: ExpandOpts = ExpandOpts {
27 : fragment: false,
28 : allow_null: true,
29 : merge: false,
30 : temporal: true,
31 : sys: false,
32 : };
33 :
34 : /// 4.5.7 and 4.5.8: each recorded instance of a Property or Relationship
35 : /// carries an instanceId and the 4.8 timestamps. The id is minted here and
36 : /// only when the pushed instance brought none, because the temporal API is
37 : /// add-only (5.6.11.4, 5.6.12.1) and 5.6.14/5.6.15 address an instance by
38 : /// that id. Sub-Attributes are part of the instance and are not stamped
39 : /// separately -- the current-state path stamps them one level deeper
40 : /// (`crate::stamp::stamp_instances`).
41 316 : fn stamp_temporal_instances(doc: &mut Value, ts: &str) {
42 316 : if let Some(obj) = doc.as_object_mut() {
43 1072 : for (k, v) in obj.iter_mut() {
44 1072 : if is_meta(k) {
45 664 : continue;
46 408 : }
47 408 : if let Some(arr) = v.as_array_mut() {
48 586 : for inst in arr {
49 586 : if let Some(o) = inst.as_object_mut() {
50 586 : o.entry("instanceId".to_owned()).or_insert_with(|| {
51 586 : Value::String(format!("urn:ngsi-ld:Instance:{}", uuid::Uuid::new_v4()))
52 586 : });
53 586 : o.insert("createdAt".into(), Value::String(ts.to_owned()));
54 586 : o.insert("modifiedAt".into(), Value::String(ts.to_owned()));
55 0 : }
56 : }
57 0 : }
58 : }
59 0 : }
60 316 : }
61 :
62 : // ---------- POST /temporal/entities/ — Upsert temporal (5.6.11) ----------
63 :
64 816 : pub async fn upsert_temporal(
65 816 : State(st): State<AppState>,
66 816 : CleanParams(params): CleanParams,
67 816 : headers: HeaderMap,
68 816 : body: Bytes,
69 816 : ) -> Response {
70 816 : let go = async {
71 816 : let tenant = tenant_from(&headers)?;
72 816 : check_params(¶ms, &["options", "local"])?;
73 812 : let parsed = parse_body(&st.loader, &headers, &body, BodyKind::Standard).await?;
74 324 : let obj = parsed.object(NgsiError::BadRequestData(
75 324 : "temporal entity must be a JSON object".into(),
76 324 : ))?;
77 324 : let expanded = expand_entity(obj, &parsed.ctx, TEMPORAL_OPTS)?;
78 312 : let id = antares_jsonld::expanded_id(&expanded)?.to_owned();
79 312 : gate!(st, &tenant, &headers, "5.6.11", ids: &[&id]).await?;
80 : // 5.6.11.4: exclusive/redirect registrations matching the input are
81 : // forwarded when "Create or Update Temporal" is supported; proxy
82 : // modes without it are an error of type Conflict; inclusive ones
83 : // forward when supported. Matching attributes are removed from the
84 : // local fragment.
85 312 : let spec = crate::registry::CsrSpec {
86 312 : ids: Some(vec![id.clone()]),
87 312 : ..Default::default()
88 312 : };
89 312 : let regs = match crate::federation::write_plan(
90 312 : &st,
91 312 : &tenant,
92 312 : &spec,
93 312 : &parsed.ctx,
94 312 : ¶ms,
95 312 : &headers,
96 312 : )
97 312 : .await?
98 : {
99 0 : crate::federation::WritePlan::Answered(r) => return Ok(*r),
100 312 : crate::federation::WritePlan::Forward(regs) => regs,
101 : };
102 312 : if !regs.is_empty() {
103 4 : let mut parts = Vec::new();
104 4 : let mut fwd = Vec::new();
105 4 : for reg in ®s {
106 4 : if !reg.supports("upsertTemporal") {
107 2 : if reg.is_proxy() {
108 2 : parts.push(crate::federation::conflict_part("upsertTemporal"));
109 2 : }
110 2 : continue;
111 2 : }
112 2 : if let Some(frag) = crate::federation::reduce_to_scope(obj, reg, &parsed.ctx) {
113 2 : fwd.push((reg.clone(), frag));
114 2 : }
115 : }
116 4 : let proxies: Vec<&crate::federation::FedReg> =
117 4 : regs.iter().filter(|r| r.is_proxy()).collect();
118 4 : let (rest, has_attrs) = crate::federation::strip_proxied(obj, &proxies, &parsed.ctx);
119 4 : if has_attrs || proxies.is_empty() {
120 0 : let local = expand_entity(&rest, &parsed.ctx, TEMPORAL_OPTS)?;
121 0 : let status = upsert_temporal_local(&st, &tenant, &id, local).await?;
122 0 : parts.push(crate::federation::Part {
123 0 : status: status.as_u16(),
124 0 : detail: "local temporal upsert".into(),
125 0 : });
126 4 : }
127 4 : let ctx_url = crate::federation::ctx_link_url(&headers, &parsed.ctx.source);
128 4 : for (reg, frag) in fwd {
129 2 : parts.push(
130 2 : crate::federation::forward_part(
131 2 : &st,
132 2 : reqwest::Method::POST,
133 2 : format!("{}/ngsi-ld/v1/temporal/entities", reg.endpoint),
134 2 : &[],
135 2 : &headers,
136 2 : &tenant,
137 2 : ®,
138 2 : &ctx_url,
139 2 : Some(frag),
140 2 : )
141 2 : .await,
142 : );
143 : }
144 4 : return Ok(crate::federation::combine(
145 4 : parts,
146 4 : created(
147 4 : format!(
148 4 : "/ngsi-ld/v1/temporal/entities/{}",
149 4 : crate::federation::path_segment(&id)
150 4 : ),
151 4 : &tenant,
152 4 : ),
153 4 : &tenant,
154 4 : ));
155 308 : }
156 308 : let status = upsert_temporal_local(&st, &tenant, &id, expanded).await?;
157 308 : Ok::<_, ApiError>(if status == StatusCode::CREATED {
158 270 : created(
159 270 : format!(
160 : "/ngsi-ld/v1/temporal/entities/{}",
161 270 : crate::federation::path_segment(&id)
162 : ),
163 270 : &tenant,
164 : )
165 : } else {
166 38 : no_content(&tenant)
167 : })
168 816 : };
169 816 : go.await.unwrap_or_else(|e| e.into_response())
170 816 : }
171 :
172 : /// 5.6.11.4 local half: create the Temporal Evolution, or add the provided
173 : /// instances to the existing one per 5.6.12 (merge key = datasetId +
174 : /// observedAt) with Entity Type names unioned. Returns 201 vs 204.
175 308 : async fn upsert_temporal_local(
176 308 : st: &AppState,
177 308 : tenant: &antares_model::TenantId,
178 308 : id: &str,
179 308 : mut expanded: Value,
180 308 : ) -> ApiResult<StatusCode> {
181 308 : let ts = now_iso();
182 308 : stamp_temporal_instances(&mut expanded, &ts);
183 : // get->create/mutate is a TOCTOU pair: two concurrent first-upserts
184 : // both see "absent", and the loser's create must NOT be silently
185 : // dropped (201 with a discarded payload). Loop: a lost create retries
186 : // as a merge, a mutate on a just-deleted doc retries as a create.
187 308 : let mut attempts = 0;
188 : loop {
189 308 : attempts += 1;
190 308 : if attempts > 16 {
191 0 : return Err(NgsiError::InternalError("upsert retry storm".into()).into());
192 308 : }
193 308 : let existed = st.temporal.get(tenant, id).await?.is_some();
194 308 : if existed {
195 38 : let res = st
196 38 : .temporal
197 38 : .mutate(tenant, id, |doc| {
198 38 : let target = antares_store::stored_object(doc)?;
199 : // 5.6.11.4: new Entity Type names are added to the target
200 38 : if let Some(new_types) = expanded.get("type").and_then(Value::as_array) {
201 38 : let mut cur: Vec<Value> = target
202 38 : .get("type")
203 38 : .and_then(Value::as_array)
204 38 : .cloned()
205 38 : .unwrap_or_default();
206 46 : for t in new_types {
207 46 : if !cur.contains(t) {
208 8 : cur.push(t.clone());
209 38 : }
210 : }
211 38 : target.insert("type".into(), Value::Array(cur));
212 0 : }
213 118 : for (k, v) in antares_jsonld::expanded_object(&expanded)? {
214 118 : if is_meta(k) {
215 76 : continue;
216 42 : }
217 42 : let incoming = v.as_array().cloned().unwrap_or_default();
218 42 : match target.get_mut(k).and_then(Value::as_array_mut) {
219 42 : Some(cur) => {
220 : // 5.6.11: instances merge by (datasetId, observedAt)
221 42 : for ni in incoming {
222 42 : let key = (
223 42 : ni.get("datasetId")
224 42 : .and_then(Value::as_str)
225 42 : .map(String::from),
226 42 : ni.get("observedAt")
227 42 : .and_then(Value::as_str)
228 42 : .map(String::from),
229 42 : );
230 42 : let pos = cur.iter().position(|ci| {
231 42 : (
232 42 : ci.get("datasetId")
233 42 : .and_then(Value::as_str)
234 42 : .map(String::from),
235 42 : ci.get("observedAt")
236 42 : .and_then(Value::as_str)
237 42 : .map(String::from),
238 42 : ) == key
239 2 : && key.1.is_some()
240 42 : });
241 42 : match pos {
242 : // A correction, not a new instance: it
243 : // keeps the instanceId its client was
244 : // handed and the createdAt it was created
245 : // at, which is 5.6.14.4's rule for the
246 : // same kind of in-place change ("The
247 : // createdAt property of the concerned
248 : // instance shall remain unchanged").
249 : // `stamp_temporal_instances` has already put a
250 : // fresh pair on the incoming instance.
251 2 : Some(p) => {
252 2 : let mut ni = ni;
253 4 : for keep in ["instanceId", "createdAt"] {
254 4 : let Some(had) = cur[p].get(keep).cloned() else {
255 0 : continue;
256 : };
257 4 : if let Some(o) = ni.as_object_mut() {
258 4 : o.insert(keep.to_owned(), had);
259 4 : }
260 : }
261 2 : cur[p] = ni;
262 : }
263 40 : None => cur.push(ni),
264 : }
265 : }
266 : }
267 0 : None => {
268 0 : target.insert(k.clone(), Value::Array(incoming));
269 0 : }
270 : }
271 : }
272 38 : target.insert("modifiedAt".into(), Value::String(ts.clone()));
273 38 : Ok::<(), NgsiError>(())
274 38 : })
275 38 : .await?;
276 38 : match res {
277 0 : Some(Err(e)) => return Err(ApiError::from(e)),
278 38 : Some(Ok(())) => return Ok(StatusCode::NO_CONTENT),
279 0 : None => continue, // deleted between get and mutate - retry as create
280 : }
281 : } else {
282 270 : let mut doc = expanded.clone();
283 270 : if let Some(o) = doc.as_object_mut() {
284 270 : o.insert("createdAt".into(), Value::String(ts.clone()));
285 270 : o.insert("modifiedAt".into(), Value::String(ts.clone()));
286 270 : }
287 270 : if st.temporal.create(tenant, id, doc).await? {
288 270 : return Ok(StatusCode::CREATED);
289 0 : }
290 : // lost the create race - the doc exists now; retry as a merge
291 : }
292 : }
293 308 : }
294 :
295 : // ---------- temporal query params (4.11) ----------
296 :
297 : /// Windowed per-entity temporal data: filtered+ordered instances per attr.
298 : struct Windowed {
299 : attrs: std::collections::BTreeMap<String, Vec<Value>>,
300 : max_per_attr: usize,
301 : ts_min: Option<String>,
302 : ts_max: Option<String>,
303 : truncated: bool,
304 : }
305 :
306 : /// NGSI-LD 6.3.10: the most instances of one Attribute the broker serves in
307 : /// one response — beyond it the representation is cut and answered "206" with
308 : /// a Content-Range. The ETSI suite triggers 206 at 20 instances and expects
309 : /// 200 at <=5, so any limit in (5,20) is spec-valid; 9 keeps margin.
310 : const TEMPORAL_INSTANCE_LIMIT: usize = 9;
311 :
312 : /// 6.3.10: the ceiling is a CUT, not a label. An Attribute holding more
313 : /// instances than the broker serves at once is truncated to the ceiling in
314 : /// the query direction, and the 206 + Content-Range then describes the
315 : /// instances actually returned. This is what caps a lastN above the ceiling
316 : /// and what caps a request naming no temporal window at all. Aggregated
317 : /// representations (5.7.4.4) are computed over the whole evolution and are
318 : /// complete by construction, so they are never cut.
319 : ///
320 : /// The cut is ONE time boundary for the whole entity, not a per-attribute
321 : /// count: the partial content "shall" be the representation the
322 : /// Content-Range describes, so every attribute is trimmed to the tightest
323 : /// ceiling instant among the over-full ones (ties at that instant kept),
324 : /// and a client continuing from the advertised range-end misses no instance
325 : /// of any attribute. An attribute lying entirely beyond the boundary comes
326 : /// back empty on this page.
327 678 : fn truncate(w: &mut Windowed, timeprop: &str, descending: bool) {
328 678 : if w.max_per_attr <= TEMPORAL_INSTANCE_LIMIT {
329 654 : return;
330 24 : }
331 24 : w.truncated = true;
332 24 : w.max_per_attr = TEMPORAL_INSTANCE_LIMIT;
333 660 : let key = |inst: &Value| inst.get(timeprop).and_then(Value::as_str).map(dt_key);
334 : // the tightest ceiling instant: earliest forwards, latest backwards
335 24 : let boundary = w
336 24 : .attrs
337 24 : .values()
338 32 : .filter(|insts| insts.len() > TEMPORAL_INSTANCE_LIMIT)
339 32 : .filter_map(|insts| key(&insts[TEMPORAL_INSTANCE_LIMIT - 1]))
340 24 : .reduce(|a, b| if (b < a) != descending { b } else { a });
341 24 : let (mut ts_min, mut ts_max) = (None::<String>, None::<String>);
342 32 : for instances in w.attrs.values_mut() {
343 32 : match &boundary {
344 628 : Some(bd) => instances.retain(|inst| {
345 628 : key(inst).is_none_or(|k| if descending { k >= *bd } else { k <= *bd })
346 628 : }),
347 0 : None => instances.truncate(TEMPORAL_INSTANCE_LIMIT),
348 : }
349 232 : for inst in instances.iter() {
350 232 : if let Some(t) = inst.get(timeprop).and_then(Value::as_str) {
351 232 : if ts_min.as_deref().is_none_or(|m| dt_key(t) < dt_key(m)) {
352 92 : ts_min = Some(t.to_owned());
353 140 : }
354 232 : if ts_max.as_deref().is_none_or(|m| dt_key(t) > dt_key(m)) {
355 132 : ts_max = Some(t.to_owned());
356 134 : }
357 0 : }
358 : }
359 : }
360 24 : (w.ts_min, w.ts_max) = (ts_min, ts_max);
361 678 : }
362 :
363 : /// 5.7.4.4 S4/S7: does a scope VALUE (string or array of strings) match the
364 : /// 4.19 Scope query? The 4.5.7 deletion sentinel never matches.
365 356 : fn scope_value_matches(sq: &str, v: &Value) -> bool {
366 356 : if v.is_null() || v.as_str() == Some("urn:ngsi-ld:null") {
367 0 : return false;
368 356 : }
369 356 : crate::scope_matches(sq, &serde_json::json!({ "scope": v }))
370 356 : }
371 :
372 : /// 4.18 over a Temporal Evolution: "a given Scope is considered valid from
373 : /// the time it has been set until the time it has been explicitly removed by
374 : /// an update or delete operation" (example: annex C.5.16). Instance-shaped
375 : /// scope arrays become [set-time, next-set-time) validity intervals
376 : /// (set-time = observedAt‖modifiedAt‖createdAt — 4.5.6 mirrors them from the
377 : /// Core API change); a plain string/array scope is valid for all time.
378 : /// Returns only the intervals whose value matches `sq`; "" start = -inf,
379 : /// None end = +inf.
380 368 : fn scope_match_intervals(doc: &Value, sq: &str) -> Vec<(String, Option<String>)> {
381 368 : match doc.get("scope") {
382 308 : Some(Value::Array(a)) if a.first().is_some_and(Value::is_object) => {
383 304 : let mut states: Vec<(&str, &Value)> = a
384 304 : .iter()
385 356 : .filter_map(|i| {
386 356 : scope_set_time(i).map(|t| (t, i.get("value").unwrap_or(&Value::Null)))
387 356 : })
388 304 : .collect();
389 304 : states.sort_by_key(|(t, _)| dt_key(t));
390 304 : (0..states.len())
391 356 : .filter(|&n| scope_value_matches(sq, states[n].1))
392 304 : .map(|n| {
393 : (
394 108 : states[n].0.to_owned(),
395 108 : states.get(n + 1).map(|(t, _)| (*t).to_owned()),
396 : )
397 108 : })
398 304 : .collect()
399 : }
400 4 : Some(_) if crate::scope_matches(sq, doc) => vec![(String::new(), None)],
401 62 : _ => Vec::new(),
402 : }
403 368 : }
404 :
405 : /// The time a temporal scope instance was set (4.5.6: observedAt is a copy
406 : /// of modifiedAt on Core-API changes; direct 5.6.11 input may carry any).
407 550 : fn scope_set_time(i: &Value) -> Option<&str> {
408 550 : ["observedAt", "modifiedAt", "createdAt"]
409 550 : .iter()
410 550 : .find_map(|k| i.get(*k).and_then(Value::as_str))
411 550 : }
412 :
413 696 : fn window(
414 696 : doc: &Value,
415 696 : tq: Option<&TemporalQ>,
416 696 : last_n: Option<usize>,
417 696 : attrs_filter: Option<&Vec<String>>,
418 696 : omit: Option<&Vec<crate::repr::ProjNode>>,
419 696 : dataset: Option<&Vec<String>>,
420 696 : timeprop: &str,
421 696 : ) -> Windowed {
422 696 : let mut w = Windowed {
423 696 : attrs: std::collections::BTreeMap::new(),
424 696 : max_per_attr: 0,
425 696 : ts_min: None,
426 696 : ts_max: None,
427 696 : truncated: false,
428 696 : };
429 696 : let Some(obj) = doc.as_object() else { return w };
430 3798 : for (k, v) in obj {
431 : // 4.5.6: the Scope of a Temporal Evolution is represented as the
432 : // temporal representation of a Property — instance-shaped scope
433 : // arrays window like attributes (plain-string scope stays meta).
434 3798 : let scope_instances = k == "scope"
435 102 : && v.as_array()
436 102 : .is_some_and(|a| a.first().is_some_and(Value::is_object));
437 3798 : if is_meta(k) && !scope_instances {
438 2714 : continue;
439 1084 : }
440 1084 : if let Some(want) = attrs_filter {
441 26 : if !want.contains(k) {
442 12 : continue;
443 14 : }
444 1058 : }
445 1072 : if let Some(omit) = omit {
446 20 : if omit.iter().any(|n| n.iri == *k && n.children.is_none()) {
447 10 : continue;
448 10 : }
449 1052 : }
450 1062 : let mut instances: Vec<Value> = v
451 1062 : .as_array()
452 1062 : .cloned()
453 1062 : .unwrap_or_default()
454 1062 : .into_iter()
455 1948 : .filter(|inst| tq.is_none_or(|tq| tq.instance_matches(inst)))
456 1836 : .filter(|inst| match (dataset, inst.get("datasetId")) {
457 1836 : (None, _) => true,
458 0 : (Some(want), Some(Value::String(have))) => want.iter().any(|w| w == have),
459 0 : (Some(want), None) => want.iter().any(|w| w == "@none"),
460 0 : _ => false,
461 1836 : })
462 1062 : .collect();
463 : // 4.18/C.5.16: the scope valid AT the window start was set at or
464 : // before it — carry the latest pre-window instance into the
465 : // representation (a temporal scope stays valid until replaced).
466 1062 : if scope_instances {
467 96 : let start = tq.and_then(|t| match t.timerel.as_str() {
468 94 : "after" | "between" => Some(t.time_at.as_str()),
469 0 : _ => None,
470 94 : });
471 96 : if let Some(start) = start {
472 94 : let carry = v
473 94 : .as_array()
474 94 : .into_iter()
475 94 : .flatten()
476 114 : .filter(|i| scope_set_time(i).is_some_and(|t| dt_key(t) < dt_key(start)))
477 94 : .max_by_key(|i| scope_set_time(i).map(dt_key))
478 94 : .cloned();
479 94 : if let Some(c) = carry {
480 80 : if !instances.contains(&c) {
481 80 : instances.push(c);
482 80 : }
483 14 : }
484 2 : }
485 966 : }
486 1062 : instances.sort_by(|a, b| {
487 : // Canonicalize before comparing: '.' sorts before 'Z', so a raw
488 : // string compare puts "…00.5Z" ahead of "…00Z" and lastN keeps the
489 : // wrong instant. 4.6.3 allows both fraction spellings.
490 854 : let ta = a.get(timeprop).and_then(Value::as_str).unwrap_or("");
491 854 : let tb = b.get(timeprop).and_then(Value::as_str).unwrap_or("");
492 854 : dt_key(ta).cmp(&dt_key(tb))
493 854 : });
494 1062 : if let Some(n) = last_n {
495 20 : if instances.len() > n {
496 12 : instances = instances.split_off(instances.len() - n);
497 12 : }
498 : // lastN delivers newest-first (DESC), Scorpio parity
499 20 : instances.reverse();
500 1042 : }
501 1062 : if instances.is_empty() {
502 0 : continue;
503 1062 : }
504 1062 : w.max_per_attr = w.max_per_attr.max(instances.len());
505 1884 : for inst in &instances {
506 1884 : if let Some(t) = inst.get(timeprop).and_then(Value::as_str) {
507 1846 : if w.ts_min.as_deref().is_none_or(|m| dt_key(t) < dt_key(m)) {
508 966 : w.ts_min = Some(t.to_owned());
509 988 : }
510 1846 : if w.ts_max.as_deref().is_none_or(|m| dt_key(t) > dt_key(m)) {
511 1132 : w.ts_max = Some(t.to_owned());
512 1132 : }
513 38 : }
514 : }
515 1062 : w.attrs.insert(k.clone(), instances);
516 : }
517 696 : w
518 696 : }
519 :
520 : /// `Content-Range: date-time <start>-<end>/<size>` (Scorpio-parity semantics).
521 704 : fn content_range(
522 704 : truncated: bool,
523 704 : ts_min: Option<&str>,
524 704 : ts_max: Option<&str>,
525 704 : tq: Option<&TemporalQ>,
526 704 : last_n: Option<usize>,
527 704 : ) -> Option<String> {
528 704 : if !truncated {
529 690 : return None;
530 14 : }
531 14 : let (data_min, data_max) = (ts_min?, ts_max?);
532 : // The window bound is the query's own when the query names one; matching
533 : // on the pair keeps the timerel and the query that produced it together,
534 : // so no arm can reach for a query that is not there.
535 14 : let named = tq.filter(|t| t.timerel != "any");
536 14 : let (start, end) = if last_n.is_none() {
537 10 : let start = match named.map(|t| (t.timerel.as_str(), t)) {
538 6 : Some(("after" | "between", t)) => t.time_at.clone(),
539 4 : _ => data_min.to_owned(),
540 : };
541 10 : (start, data_max.to_owned())
542 : } else {
543 4 : let start = match named.map(|t| (t.timerel.as_str(), t)) {
544 0 : Some(("before", t)) => t.time_at.clone(),
545 0 : Some(("between", t)) => t.end_time_at.clone().unwrap_or_else(|| data_max.to_owned()),
546 4 : _ => data_max.to_owned(),
547 : };
548 4 : (start, data_min.to_owned())
549 : };
550 : // start/end bound the instances actually returned; the size is the length
551 : // of the complete representation the client asked for — the requested
552 : // lastN, or "*" when the window leaves it unknown.
553 14 : let size = last_n.map_or_else(|| "*".to_owned(), |n| n.to_string());
554 14 : Some(format!("date-time {start}-{end}/{size}"))
555 704 : }
556 :
557 : /// Render one temporal entity from its windowed data.
558 676 : fn present_temporal(
559 676 : doc: &Value,
560 676 : w: &Windowed,
561 676 : ctx: &Context,
562 676 : r: &TRepr,
563 676 : tq: Option<&TemporalQ>,
564 676 : timeprop: &str,
565 676 : ) -> Result<Value, NgsiError> {
566 676 : let Some(obj) = doc.as_object() else {
567 0 : return Ok(doc.clone());
568 : };
569 676 : let mut out = Map::new();
570 3754 : for (k, v) in obj {
571 3754 : let scope_instances = k == "scope"
572 102 : && v.as_array()
573 102 : .is_some_and(|a| a.first().is_some_and(Value::is_object));
574 3754 : if is_meta(k) && !scope_instances {
575 2690 : match k.as_str() {
576 : // Table 6.3.11-1: `sysAttrs` is what admits "the system
577 : // generated temporal attributes createdAt, modifiedAt and
578 : // the system temporal attribute expiresAt … In the case of
579 : // temporal representations, also the system generated
580 : // temporal attribute deletedAt". Without it none of them is
581 : // in the payload — the same set `repr.rs` gates on the
582 : // current-state path.
583 2690 : "createdAt" | "modifiedAt" | "expiresAt" | "deletedAt" if !r.sys => continue,
584 1432 : _ => {}
585 : }
586 1432 : if !crate::repr::meta_projected(r.pick.as_deref(), r.omit.as_deref(), k) {
587 12 : continue;
588 1420 : }
589 1420 : if k == "type" {
590 670 : out.insert("type".into(), antares_jsonld::compact_types(v, ctx));
591 750 : } else {
592 750 : out.insert(k.clone(), v.clone());
593 750 : }
594 1064 : }
595 : }
596 676 : if r.aggregated {
597 26 : for (k, v) in render_aggregated(w, tq, r, ctx, timeprop)? {
598 12 : out.insert(k, v);
599 12 : }
600 20 : return Ok(Value::Object(out));
601 650 : }
602 1008 : for (k, instances) in &w.attrs {
603 : // Table 6.18.3.2-1 / 6.19.3.1 `lang`: each LanguageProperty
604 : // instance becomes a Property in the chosen language (4.15) before
605 : // either representation renders it.
606 : let reduced: Vec<Value>;
607 1008 : let instances: &[Value] = match &r.lang {
608 48 : Some(lang) => {
609 48 : reduced = instances
610 48 : .iter()
611 72 : .map(|inst| {
612 72 : let mut inst = inst.clone();
613 72 : if let Some(o) = inst.as_object_mut() {
614 72 : crate::repr::apply_lang(o, lang);
615 72 : }
616 72 : inst
617 72 : })
618 48 : .collect();
619 48 : &reduced
620 : }
621 960 : None => instances,
622 : };
623 1008 : if instances.is_empty() {
624 : // gap-cut leftovers render as empty arrays
625 0 : out.insert(ctx.compact_iri(k), Value::Array(vec![]));
626 0 : continue;
627 1008 : }
628 1008 : if r.temporal_values {
629 : // group instances by datasetId (4.5.9)
630 44 : let mut groups: Vec<(Option<String>, Vec<&Value>)> = Vec::new();
631 56 : for inst in instances {
632 56 : let ds = inst
633 56 : .get("datasetId")
634 56 : .and_then(Value::as_str)
635 56 : .map(String::from);
636 56 : match groups.iter_mut().find(|(g, _)| *g == ds) {
637 12 : Some((_, list)) => list.push(inst),
638 44 : None => groups.push((ds, vec![inst])),
639 : }
640 : }
641 44 : let mut rendered: Vec<Value> = groups
642 44 : .iter()
643 44 : .map(|(ds, list)| {
644 44 : let atype = list
645 44 : .first()
646 44 : .and_then(|i| i.get("type"))
647 44 : .cloned()
648 44 : .unwrap_or_else(|| Value::String("Property".into()));
649 44 : let values: Vec<Value> = list
650 44 : .iter()
651 56 : .map(|inst| {
652 : // 4.5.9: Property/Relationship pairs carry the bare
653 : // value/object; other attribute kinds wrap it under
654 : // their member name.
655 56 : let v = if let Some(v) = inst.get("value") {
656 : // 4.5.9: "the first element shall be a
657 : // Property value" — the one the instance
658 : // holds. A f64 round trip would retype an
659 : // integer and drop a digit past 2^53.
660 36 : v.clone()
661 20 : } else if let Some(o) = inst.get("object") {
662 2 : o.clone()
663 18 : } else if let Some(lm) = inst.get("languageMap") {
664 2 : serde_json::json!({"languageMap": lm})
665 16 : } else if let Some(j) = inst.get("json") {
666 0 : serde_json::json!({"json": j})
667 16 : } else if let Some(vv) = inst.get("vocab") {
668 0 : let compacted = match vv {
669 0 : Value::String(iri) => Value::String(ctx.compact_iri(iri)),
670 0 : Value::Array(a) => Value::Array(
671 0 : a.iter()
672 0 : .map(|s| match s {
673 0 : Value::String(iri) => {
674 0 : Value::String(ctx.compact_iri(iri))
675 : }
676 0 : o => o.clone(),
677 0 : })
678 0 : .collect(),
679 : ),
680 0 : o => o.clone(),
681 : };
682 0 : serde_json::json!({"vocab": compacted})
683 16 : } else if let Some(l) = inst.get("valueList") {
684 : // 4.5.9 p.63 EXAMPLE 3: the pair's first element
685 : // is the BARE ordered array, not a {"valueList"}
686 : // wrapper — unlike languageMap/
687 : // json/vocab, which the clause does wrap
688 10 : l.clone()
689 6 : } else if let Some(l) = inst.get("objectList") {
690 : // 4.5.9 p.65: same bare form for ListRelationship
691 6 : l.clone()
692 : } else {
693 0 : Value::Null
694 : };
695 56 : let t = inst.get(timeprop).cloned().unwrap_or(Value::Null);
696 56 : Value::Array(vec![v, t])
697 56 : })
698 44 : .collect();
699 44 : let mut o = Map::new();
700 : // 4.5.9: the simplified member name follows the attribute type
701 44 : let member = match atype.as_str() {
702 44 : Some("Relationship") => "objects",
703 42 : Some("LanguageProperty") => "languageMaps",
704 40 : Some("VocabProperty") => "vocabs",
705 40 : Some("JsonProperty") => "jsons",
706 40 : Some("ListProperty") => "valueLists",
707 34 : Some("ListRelationship") => "objectLists",
708 28 : _ => "values",
709 : };
710 44 : o.insert("type".into(), atype);
711 44 : if let Some(ds) = ds {
712 0 : o.insert("datasetId".into(), Value::String(ds.clone()));
713 44 : }
714 44 : o.insert(member.into(), Value::Array(values));
715 44 : Value::Object(o)
716 44 : })
717 44 : .collect();
718 44 : let rendered = if rendered.len() == 1 {
719 44 : rendered.remove(0)
720 : } else {
721 0 : Value::Array(rendered)
722 : };
723 44 : out.insert(ctx.compact_iri(k), rendered);
724 : } else {
725 964 : let presented: Vec<Value> = instances
726 964 : .iter()
727 1144 : .map(|inst| {
728 1144 : let mut ci = inst.clone();
729 1144 : if !r.sys {
730 1106 : if let Some(o) = ci.as_object_mut() {
731 1106 : o.remove("createdAt");
732 1106 : o.remove("modifiedAt");
733 1106 : // 6.3.11: expiresAt is sysAttrs-gated too
734 1106 : o.remove("expiresAt");
735 1106 : }
736 38 : }
737 1144 : compact_instance(&ci, ctx)
738 1144 : })
739 964 : .collect();
740 964 : out.insert(ctx.compact_iri(k), Value::Array(presented));
741 : }
742 : }
743 650 : Ok(Value::Object(out))
744 676 : }
745 :
746 : /// Parsed temporal representation params (options/format/lastN/pick/omit/
747 : /// datasetId/aggregation), fully validated up front.
748 : #[derive(Default, Clone)]
749 : struct TRepr {
750 : temporal_values: bool,
751 : aggregated: bool,
752 : sys: bool,
753 : last_n: Option<usize>,
754 : pick: Option<Vec<crate::repr::ProjNode>>,
755 : omit: Option<Vec<crate::repr::ProjNode>>,
756 : dataset_id: Option<Vec<String>>,
757 : attrs: Option<Vec<String>>,
758 : aggr_methods: Vec<String>,
759 : aggr_period: AggrPeriod,
760 : lang: Option<String>,
761 : }
762 :
763 : #[derive(Clone, Copy, Debug, Default, PartialEq)]
764 : enum AggrPeriod {
765 : /// PT0S / absent: one bucket over the whole range
766 : #[default]
767 : Whole,
768 : Seconds(i64),
769 : /// 4.5.19.1: a period may mix date and time elements
770 : /// ("P3Y6M4DT12H30M5S"), so a month step carries the leftover seconds —
771 : /// months are not a fixed number of seconds and cannot be folded in.
772 : Months(u32, i64),
773 : }
774 :
775 92 : fn parse_iso_duration(s: &str) -> Option<AggrPeriod> {
776 92 : let d = antares_model::parse_iso_duration(s)?;
777 : // saturating: an absurd magnitude must not panic (debug) or wrap
778 : // (release) — f64→int `as` casts already saturate, guard the ops.
779 72 : let months = (d.years as u32)
780 72 : .saturating_mul(12)
781 72 : .saturating_add(d.months as u32);
782 72 : let secs = [
783 72 : (d.weeks, 604_800.0),
784 72 : (d.days, 86_400.0),
785 72 : (d.hours, 3_600.0),
786 72 : (d.minutes, 60.0),
787 72 : (d.seconds, 1.0),
788 72 : ]
789 72 : .into_iter()
790 360 : .fold(0i64, |acc, (n, per)| acc.saturating_add((n * per) as i64));
791 72 : Some(match (months, secs) {
792 30 : (0, 0) => AggrPeriod::Whole,
793 18 : (0, sc) => AggrPeriod::Seconds(sc),
794 24 : (m, sc) => AggrPeriod::Months(m, sc),
795 : })
796 92 : }
797 :
798 : const AGGR_METHODS: &[&str] = &[
799 : "totalCount",
800 : "distinctCount",
801 : "sum",
802 : "avg",
803 : "min",
804 : "max",
805 : "stddev",
806 : "sumsq",
807 : ];
808 :
809 : /// 5.7.3.4 / 5.7.4.4: "If projection attributes are present and indicate the
810 : /// use of Linked Entity retrieval, an error of type BadRequestData shall be
811 : /// raised." Unconditional on both temporal consumption operations, because
812 : /// neither defines a join; only the clause number in the message differs.
813 808 : fn reject_linked_projection(trepr: &TRepr, clause: &str) -> Result<(), NgsiError> {
814 1596 : let depth = |p: &Option<Vec<crate::repr::ProjNode>>| {
815 1596 : p.as_deref().map(crate::repr::proj_depth).unwrap_or(0)
816 1596 : };
817 808 : if depth(&trepr.pick) > 0 || depth(&trepr.omit) > 0 {
818 24 : return Err(NgsiError::BadRequestData(format!(
819 24 : "temporal projection must not use Linked Entity selection ({clause})"
820 24 : )));
821 784 : }
822 784 : Ok(())
823 808 : }
824 :
825 872 : fn parse_trepr(params: &HashMap<String, String>, ctx: &Context) -> Result<TRepr, NgsiError> {
826 872 : let mut r = TRepr {
827 872 : lang: params.get("lang").cloned(),
828 872 : ..TRepr::default()
829 872 : };
830 872 : if let Some(opts) = params.get("options") {
831 78 : for o in opts.split(',') {
832 78 : match o.trim() {
833 78 : "sysAttrs" => r.sys = true,
834 42 : "temporalValues" => r.temporal_values = true,
835 36 : "aggregatedValues" => r.aggregated = true,
836 0 : "normalized" => {}
837 0 : other => {
838 0 : return Err(NgsiError::InvalidRequest(format!(
839 0 : "unsupported options value {other:?}"
840 0 : )))
841 : }
842 : }
843 : }
844 794 : }
845 : // format wins over options on conflict (6.3.7)
846 872 : if let Some(f) = params.get("format") {
847 42 : match f.as_str() {
848 42 : "temporalValues" => {
849 10 : r.temporal_values = true;
850 10 : r.aggregated = false;
851 10 : }
852 32 : "aggregatedValues" => {
853 32 : r.aggregated = true;
854 32 : r.temporal_values = false;
855 32 : }
856 0 : "normalized" => {
857 0 : r.temporal_values = false;
858 0 : r.aggregated = false;
859 0 : }
860 0 : other => {
861 0 : return Err(NgsiError::InvalidRequest(format!(
862 0 : "unsupported format value {other:?}"
863 0 : )))
864 : }
865 : }
866 830 : }
867 872 : crate::repr::check_projection_exclusive(params)?;
868 856 : if let Some(pck) = params.get("pick") {
869 36 : r.pick = Some(crate::repr::parse_projection(pck, ctx)?);
870 820 : }
871 856 : if let Some(o) = params.get("omit") {
872 8 : r.omit = Some(crate::repr::parse_projection(o, ctx)?);
873 848 : }
874 856 : if let Some(a) = params.get("attrs") {
875 20 : r.attrs = Some(a.split(',').map(|t| ctx.expand_key(t.trim())).collect());
876 836 : }
877 856 : r.dataset_id = params
878 856 : .get("datasetId")
879 856 : .map(|s| s.split(',').map(|d| d.trim().to_owned()).collect());
880 856 : r.last_n = match params.get("lastN") {
881 20 : Some(n) => {
882 : // 5.2.21: lastN is a POSITIVE integer — 0 is outside the value
883 : // space.
884 20 : let v = n
885 20 : .parse::<usize>()
886 20 : .ok()
887 20 : .filter(|v| *v >= 1)
888 20 : .ok_or_else(|| NgsiError::BadRequestData(format!("invalid lastN {n:?}")))?;
889 : // Above i64::MAX it wraps negative when bound as the RANK cap
890 : // (`rk <= $n::bigint`), silently returning an empty set.
891 12 : if v > i64::MAX as usize {
892 0 : return Err(NgsiError::BadRequestData(format!(
893 0 : "lastN {v} is out of range"
894 0 : )));
895 12 : }
896 12 : Some(v)
897 : }
898 836 : None => None,
899 : };
900 848 : if let Some(m) = params.get("aggrMethods") {
901 100 : for method in m.split(',') {
902 100 : let method = method.trim();
903 100 : if !AGGR_METHODS.contains(&method) {
904 12 : return Err(NgsiError::BadRequestData(format!(
905 12 : "invalid aggrMethods value {method:?} (4.5.19)"
906 12 : )));
907 88 : }
908 88 : r.aggr_methods.push(method.to_owned());
909 : }
910 : // aggrMethods implies aggregation UNLESS an explicit format says otherwise
911 56 : if !params.contains_key("format") {
912 32 : r.aggregated = true;
913 38 : }
914 780 : }
915 836 : if r.aggregated && r.aggr_methods.is_empty() {
916 4 : return Err(NgsiError::BadRequestData(
917 4 : "aggregatedValues requires aggrMethods (4.5.19)".into(),
918 4 : ));
919 832 : }
920 832 : if let Some(d) = params.get("aggrPeriodDuration") {
921 28 : let p = parse_iso_duration(d).ok_or_else(|| {
922 8 : NgsiError::BadRequestData(format!("invalid aggrPeriodDuration {d:?}"))
923 8 : })?;
924 : // 4.11: the value space ends where duration arithmetic does —
925 : // beyond ~100 years chrono::Duration::seconds is out of bounds
926 : // (a panic, i.e. a remote 500), so such periods are rejected.
927 : // Both components are bounded at ~100 years, the months in their own
928 : // unit since a month is not a fixed number of seconds.
929 20 : let (months, secs) = match p {
930 10 : AggrPeriod::Whole => (0, 0),
931 10 : AggrPeriod::Seconds(sc) => (0, sc),
932 0 : AggrPeriod::Months(m, sc) => (m, sc),
933 : };
934 20 : if months > 1200 || secs > 86_400 * 366 * 100 {
935 4 : return Err(NgsiError::BadRequestData(format!(
936 4 : "aggrPeriodDuration {d:?} is out of range"
937 4 : )));
938 16 : }
939 16 : r.aggr_period = p;
940 804 : }
941 820 : Ok(r)
942 872 : }
943 :
944 : /// The attribute-selection set for windowing: attrs= or pick=.
945 736 : fn selection(r: &TRepr) -> Option<Vec<String>> {
946 736 : if let Some(a) = &r.attrs {
947 12 : return Some(a.clone());
948 724 : }
949 : // core-member picks (id/type/…) are presentation-only, not attr selection
950 724 : r.pick.as_ref().map(|p| {
951 12 : p.iter()
952 14 : .filter(|n| !is_meta(&n.raw))
953 12 : .map(|n| n.iri.clone())
954 12 : .collect()
955 12 : })
956 736 : }
957 :
958 : /// Aggregated representation (4.5.19): attr → `{type, <method>: [[v,start,end]]}`.
959 : /// Aggregation datatype class per 4.5.19.1 (Tables -1, -2, -3). Booleans
960 : /// count as numbers (1/0, table NOTE); a JSON String, a DateTime and a Date
961 : /// share the ordered min/max column; a Time additionally supports avg.
962 : #[derive(Clone, Copy, PartialEq, Debug)]
963 : enum AggrClass {
964 : Number,
965 : Text,
966 : /// 4.6.3 DateTime or Date: ordered, and Table 4.5.19.1-2 gives it no
967 : /// arithmetic.
968 : Instant,
969 : TimeOfDay,
970 : List,
971 : Opaque,
972 : Relationship,
973 : }
974 :
975 : /// The 4.6.3 datatype a Property instance's value carries, in either
976 : /// representation C.6 gives for one: a JSON-LD typed value
977 : /// (`{"@type": "DateTime", "@value": …}`), or a string whose `valueType`
978 : /// names the datatype and is coerced to its URI on the way in (4.5.2.2).
979 : /// Table 4.5.19.1-2 applies to the datatype, not to the spelling.
980 98 : fn value_datatype(inst: &Value) -> Option<&str> {
981 56 : fn term(s: &str) -> &str {
982 56 : s.strip_prefix(antares_jsonld::NGSI_LD_BASE).unwrap_or(s)
983 56 : }
984 98 : if let Some(vt) = inst.get("valueType").and_then(Value::as_str) {
985 4 : if inst.get("value").is_some_and(Value::is_string) {
986 4 : return Some(term(vt));
987 0 : }
988 94 : }
989 94 : inst.get("value")?
990 94 : .get("@type")
991 94 : .and_then(Value::as_str)
992 94 : .map(term)
993 98 : }
994 :
995 : /// The lexical form of a value: the string itself, or the `@value` of a
996 : /// JSON-LD typed value.
997 152 : fn lexical_of(v: &Value) -> Option<&str> {
998 152 : v.as_str()
999 152 : .or_else(|| v.get("@value").and_then(Value::as_str))
1000 152 : }
1001 :
1002 : /// The key the ordered classes compare by. A JSON String and a Date are
1003 : /// compared as written — 4.6.3 fixes the width of every component of a Date,
1004 : /// so lexicographical order is chronological — a DateTime by its canonical
1005 : /// instant, since an optional seconds fraction is written before the `Z` it
1006 : /// follows and sorts ahead of it, and a Time by its second of the day, at a
1007 : /// fixed width so one string comparison serves all four.
1008 112 : fn order_key(class: AggrClass, v: &Value) -> Option<String> {
1009 112 : let s = lexical_of(v)?;
1010 112 : Some(match class {
1011 56 : AggrClass::Instant => antares_model::dt_key(s),
1012 16 : AggrClass::TimeOfDay => format!("{:013.6}", seconds_of_day(s)?),
1013 40 : _ => s.to_owned(),
1014 : })
1015 112 : }
1016 :
1017 110 : fn classify_instance(inst: &Value) -> AggrClass {
1018 110 : if inst.get("object").is_some() {
1019 8 : return AggrClass::Relationship;
1020 102 : }
1021 102 : if inst.get("valueList").is_some() || inst.get("objectList").is_some() {
1022 0 : return AggrClass::List;
1023 102 : }
1024 102 : if inst.get("vocab").is_some()
1025 98 : || inst.get("languageMap").is_some()
1026 98 : || inst.get("json").is_some()
1027 : {
1028 : // URI / JSON-object valued kinds: only counting methods apply
1029 4 : return AggrClass::Opaque;
1030 98 : }
1031 98 : match value_datatype(inst) {
1032 56 : Some("DateTime" | "Date") => return AggrClass::Instant,
1033 20 : Some("Time") => return AggrClass::TimeOfDay,
1034 42 : _ => {}
1035 : }
1036 42 : match inst.get("value") {
1037 26 : Some(Value::Number(_)) | Some(Value::Bool(_)) => AggrClass::Number,
1038 16 : Some(Value::String(_)) => AggrClass::Text,
1039 0 : Some(Value::Array(_)) => AggrClass::List,
1040 0 : _ => AggrClass::Opaque,
1041 : }
1042 110 : }
1043 :
1044 : /// Table 4.5.19.1 eligibility: which methods apply to which datatype class.
1045 164 : fn aggr_eligible(class: AggrClass, method: &str) -> bool {
1046 164 : match method {
1047 164 : "totalCount" | "distinctCount" => true,
1048 98 : "min" | "max" => !matches!(class, AggrClass::Opaque | AggrClass::Relationship),
1049 50 : "avg" => matches!(
1050 16 : class,
1051 : AggrClass::Number | AggrClass::List | AggrClass::TimeOfDay
1052 : ),
1053 34 : "sum" => matches!(class, AggrClass::Number | AggrClass::List),
1054 16 : "stddev" | "sumsq" => matches!(class, AggrClass::Number),
1055 0 : _ => false,
1056 : }
1057 164 : }
1058 :
1059 : /// `HH:MM:SS[.f]` → seconds of day (4.6.3 Time is UTC with optional `Z`).
1060 56 : fn seconds_of_day(s: &str) -> Option<f64> {
1061 56 : let t = s.strip_suffix('Z').unwrap_or(s);
1062 56 : let b = t.as_bytes();
1063 56 : if b.len() < 8 || b[2] != b':' || b[5] != b':' {
1064 0 : return None;
1065 56 : }
1066 56 : let h: f64 = t.get(0..2)?.parse().ok()?;
1067 56 : let m: f64 = t.get(3..5)?.parse().ok()?;
1068 56 : let sec: f64 = t.get(6..)?.parse().ok()?;
1069 56 : (h < 24.0 && m < 60.0 && sec < 62.0).then_some(h * 3600.0 + m * 60.0 + sec)
1070 56 : }
1071 :
1072 : /// The raw member an instance carries its data under.
1073 212 : fn raw_of(inst: &Value) -> Option<&Value> {
1074 260 : for k in [
1075 212 : "value",
1076 212 : "object",
1077 212 : "valueList",
1078 212 : "objectList",
1079 212 : "vocab",
1080 212 : "languageMap",
1081 212 : "json",
1082 212 : ] {
1083 260 : if let Some(v) = inst.get(k) {
1084 212 : return Some(v);
1085 48 : }
1086 : }
1087 0 : None
1088 212 : }
1089 :
1090 : /// Numeric view of one raw value for the class (None ⇒ excluded from
1091 : /// numeric methods; List aggregates SIZES per Table 4.5.19.1-1).
1092 282 : fn numeric_of(class: AggrClass, v: &Value) -> Option<f64> {
1093 282 : match class {
1094 78 : AggrClass::Number => match v {
1095 0 : Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
1096 78 : _ => v.as_f64(),
1097 : },
1098 0 : AggrClass::List => v.as_array().map(|a| a.len() as f64),
1099 40 : AggrClass::TimeOfDay => lexical_of(v).and_then(seconds_of_day),
1100 164 : _ => None,
1101 : }
1102 282 : }
1103 :
1104 118 : fn render_aggregated(
1105 118 : w: &Windowed,
1106 118 : tq: Option<&TemporalQ>,
1107 118 : r: &TRepr,
1108 118 : ctx: &Context,
1109 118 : timeprop: &str,
1110 118 : ) -> Result<Map<String, Value>, NgsiError> {
1111 : use chrono::{DateTime, Datelike, FixedOffset};
1112 276 : let fmt = |d: DateTime<FixedOffset>| d.format("%Y-%m-%dT%H:%M:%SZ").to_string();
1113 118 : let mut out = Map::new();
1114 118 : for (k, instances) in &w.attrs {
1115 110 : let mut times: Vec<(DateTime<FixedOffset>, &Value)> = Vec::new();
1116 110 : let mut class: Option<AggrClass> = None;
1117 212 : for inst in instances {
1118 212 : let Some(t) = inst
1119 212 : .get(timeprop)
1120 212 : .and_then(Value::as_str)
1121 212 : .and_then(|t| DateTime::parse_from_rfc3339(t).ok())
1122 : else {
1123 0 : continue;
1124 : };
1125 212 : if class.is_none() {
1126 110 : class = Some(classify_instance(inst));
1127 110 : }
1128 212 : let Some(raw) = raw_of(inst) else { continue };
1129 212 : times.push((t, raw));
1130 : }
1131 110 : if times.is_empty() {
1132 0 : continue;
1133 110 : }
1134 : // Set alongside the first instance, and `times` is non-empty here.
1135 110 : let Some(class) = class else { continue };
1136 : // 5.7.4.4 p.211: "If an aggregated temporal representation is
1137 : // requested and any of the requested Attributes is not eligible for
1138 : // at least one of the aggregation methods specified in the request
1139 : // parameters, then an error of type InvalidRequest shall be raised."
1140 164 : for method in &r.aggr_methods {
1141 164 : if !aggr_eligible(class, method) {
1142 38 : return Err(NgsiError::InvalidRequest(format!(
1143 38 : "attribute {} ({class:?}-valued) is not eligible for \
1144 38 : aggregation method {method} (4.5.19.1, 5.7.4.4)",
1145 38 : ctx.compact_iri(k)
1146 38 : )));
1147 126 : }
1148 : }
1149 72 : times.sort_by_key(|(t, _)| *t);
1150 72 : let anchor = tq
1151 72 : .and_then(|tq| DateTime::parse_from_rfc3339(&tq.time_at).ok())
1152 72 : .unwrap_or(times[0].0);
1153 : // 4.5.19.1: "A duration of 0 second (e.g. expressed as "PT0S" or
1154 : // "P0D") is valid and is interpreted as a duration spanning the whole
1155 : // time range specified by the temporal query." The query names one
1156 : // edge of that range; 4.11 leaves the other open for `before` and
1157 : // `after`, so the data closes the open one.
1158 72 : let whole = {
1159 72 : let at = |s: &str| DateTime::parse_from_rfc3339(s).ok();
1160 72 : let (Some(&(first, _)), Some(&(last_at, _))) = (times.first(), times.last()) else {
1161 0 : continue;
1162 : };
1163 72 : let last = last_at + chrono::Duration::seconds(1);
1164 20 : match tq {
1165 32 : Some(q) if q.timerel == "before" => (first, at(&q.time_at).unwrap_or(last)),
1166 24 : Some(q) if q.timerel == "between" => (
1167 4 : at(&q.time_at).unwrap_or(first),
1168 4 : q.end_time_at.as_deref().and_then(at).unwrap_or(last),
1169 4 : ),
1170 20 : Some(q) if q.timerel == "after" => (at(&q.time_at).unwrap_or(first), last),
1171 40 : _ => (first, last),
1172 : }
1173 : };
1174 : // bucket boundaries
1175 72 : let bucket_of =
1176 160 : |t: DateTime<FixedOffset>| -> (DateTime<FixedOffset>, DateTime<FixedOffset>) {
1177 160 : match r.aggr_period {
1178 136 : AggrPeriod::Whole => whole,
1179 4 : AggrPeriod::Seconds(sc) => {
1180 : // checked throughout: an offset no representable
1181 : // date can hold puts the instant in one final
1182 : // open-ended bucket instead of panicking
1183 4 : let idx = (t - anchor).num_seconds().div_euclid(sc);
1184 4 : let bucket = idx
1185 4 : .checked_mul(sc)
1186 4 : .and_then(chrono::Duration::try_seconds)
1187 4 : .and_then(|off| anchor.checked_add_signed(off))
1188 4 : .and_then(|start| {
1189 4 : chrono::Duration::try_seconds(sc)
1190 4 : .and_then(|w| start.checked_add_signed(w))
1191 4 : .map(|end| (start, end))
1192 4 : });
1193 4 : match bucket {
1194 4 : Some(b) => b,
1195 0 : None => (anchor, chrono::DateTime::<chrono::Utc>::MAX_UTC.into()),
1196 : }
1197 : }
1198 20 : AggrPeriod::Months(m, sc) => {
1199 : // start of the k-th period, O(1) in k. Negative k are
1200 : // the periods BEFORE the anchor, which is what a
1201 : // `before` query is made of.
1202 76 : let step = |k: i64| -> Option<DateTime<FixedOffset>> {
1203 76 : let n = k.unsigned_abs().checked_mul(u64::from(m))?;
1204 76 : let n = chrono::Months::new(u32::try_from(n).ok()?);
1205 76 : let base = if k < 0 {
1206 40 : anchor.checked_sub_months(n)?
1207 : } else {
1208 36 : anchor.checked_add_months(n)?
1209 : };
1210 76 : let off = chrono::Duration::try_seconds(k.checked_mul(sc)?)?;
1211 76 : base.checked_add_signed(off)
1212 76 : };
1213 : // The whole-month distance ignores the day and the time
1214 : // of day, and one period is at least one month, so it
1215 : // brackets the exact index within one step: binary-search
1216 : // between it and the anchor instead of walking there,
1217 : // which is O(log) however far the instant is.
1218 20 : let approx = (i64::from(t.year() - anchor.year()) * 12
1219 20 : + i64::from(t.month())
1220 20 : - i64::from(anchor.month()))
1221 20 : .div_euclid(i64::from(m));
1222 20 : let (mut lo, mut hi) = (approx.min(0) - 1, approx.max(0) + 1);
1223 56 : while hi - lo > 1 {
1224 36 : let mid = lo + (hi - lo) / 2;
1225 36 : if step(mid).is_some_and(|s| s <= t) {
1226 24 : lo = mid;
1227 24 : } else {
1228 12 : hi = mid;
1229 12 : }
1230 : }
1231 : // saturate instead of panic: a huge month period or a
1232 : // far-future timeAt overflows chrono's range — treat the
1233 : // remainder as one open-ended bucket
1234 20 : match (step(lo), step(hi)) {
1235 20 : (Some(start), Some(end)) if start <= t => (start, end),
1236 0 : (Some(start), None) if start <= t => {
1237 0 : (start, chrono::DateTime::<chrono::Utc>::MAX_UTC.into())
1238 : }
1239 0 : _ => (anchor, chrono::DateTime::<chrono::Utc>::MAX_UTC.into()),
1240 : }
1241 : }
1242 : }
1243 160 : };
1244 : type Bucket = (DateTime<FixedOffset>, DateTime<FixedOffset>);
1245 72 : let mut buckets: Vec<(Bucket, Vec<&Value>)> = Vec::new();
1246 160 : for (t, v) in × {
1247 160 : let b = bucket_of(*t);
1248 160 : match buckets.last_mut() {
1249 88 : Some((bb, vals)) if bb.0 == b.0 => vals.push(v),
1250 84 : _ => buckets.push((b, vec![v])),
1251 : }
1252 : }
1253 72 : let mut attr_out = Map::new();
1254 : // 4.5.19.0: the member is labelled "Property" for Properties and
1255 : // "Relationship" for Relationships.
1256 72 : let label = if class == AggrClass::Relationship {
1257 6 : "Relationship"
1258 : } else {
1259 66 : "Property"
1260 : };
1261 72 : attr_out.insert("type".into(), Value::String(label.into()));
1262 126 : for method in &r.aggr_methods {
1263 126 : let rows: Vec<Value> = buckets
1264 126 : .iter()
1265 138 : .map(|((bs, be), vals)| {
1266 138 : let val = aggregate_bucket(method, class, vals);
1267 138 : Value::Array(vec![val, Value::String(fmt(*bs)), Value::String(fmt(*be))])
1268 138 : })
1269 126 : .collect();
1270 126 : attr_out.insert(method.clone(), Value::Array(rows));
1271 : }
1272 72 : out.insert(ctx.compact_iri(k), Value::Object(attr_out));
1273 : }
1274 80 : Ok(out)
1275 118 : }
1276 :
1277 : /// One bucket, one method — per-class semantics from Tables 4.5.19.1-1/2/3.
1278 : /// Never emits an out-of-range float (the old fold seeded with
1279 : /// f64::INFINITY, which serde_json serializes as null).
1280 138 : fn aggregate_bucket(method: &str, class: AggrClass, vals: &[&Value]) -> Value {
1281 282 : let nums: Vec<f64> = vals.iter().filter_map(|v| numeric_of(class, v)).collect();
1282 138 : let finite = |x: f64| {
1283 8 : if x.is_finite() {
1284 8 : serde_json::json!(x)
1285 : } else {
1286 0 : Value::Null
1287 : }
1288 8 : };
1289 138 : match method {
1290 138 : "totalCount" => serde_json::json!(vals.len()),
1291 80 : "distinctCount" => {
1292 : // Relationship: "count of distinct relationship TARGETS" — an
1293 : // object may be a URI or an array of URIs, so flatten first.
1294 20 : let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1295 40 : for v in vals {
1296 40 : let items: Vec<&Value> = match (class, v) {
1297 0 : (AggrClass::Relationship, Value::Array(a)) => a.iter().collect(),
1298 40 : _ => vec![*v],
1299 : };
1300 40 : for it in items {
1301 40 : seen.insert(it.to_string());
1302 40 : }
1303 : }
1304 20 : serde_json::json!(seen.len())
1305 : }
1306 60 : "min" | "max" => match class {
1307 : // ordered classes: the first or last value in the order the
1308 : // tables give the datatype, returned as it was written
1309 : AggrClass::Text | AggrClass::Instant | AggrClass::TimeOfDay => {
1310 48 : let mut keyed: Vec<(String, &Value)> = vals
1311 48 : .iter()
1312 112 : .filter_map(|v| order_key(class, v).map(|k| (k, *v)))
1313 48 : .collect();
1314 80 : keyed.sort_by(|a, b| a.0.cmp(&b.0));
1315 48 : let pick = if method == "min" {
1316 24 : keyed.first()
1317 : } else {
1318 24 : keyed.last()
1319 : };
1320 48 : pick.map_or(Value::Null, |(_, v)| (*v).clone())
1321 : }
1322 : _ => {
1323 0 : let it = nums.iter().copied();
1324 0 : let picked = if method == "min" {
1325 0 : it.fold(None, |a: Option<f64>, v| Some(a.map_or(v, |x| x.min(v))))
1326 : } else {
1327 0 : it.fold(None, |a: Option<f64>, v| Some(a.map_or(v, |x| x.max(v))))
1328 : };
1329 0 : picked.map_or(Value::Null, &finite)
1330 : }
1331 : },
1332 12 : "sum" => finite(nums.iter().sum::<f64>()),
1333 8 : "avg" => {
1334 8 : if nums.is_empty() {
1335 0 : Value::Null
1336 8 : } else if class == AggrClass::TimeOfDay {
1337 4 : let mean = nums.iter().sum::<f64>() / nums.len() as f64;
1338 4 : let (h, m, sec) = (
1339 4 : (mean / 3600.0) as u32,
1340 4 : ((mean % 3600.0) / 60.0) as u32,
1341 4 : (mean % 60.0) as u32,
1342 4 : );
1343 : // 4.6.3: a Time is `hh:mm:ssZ`, and its JSON-LD type is what
1344 : // tells a reader it is one — written bare it reads back as a
1345 : // JSON String, which Table 4.5.19.1-1 gives no average.
1346 4 : serde_json::json!({
1347 4 : "@type": "Time",
1348 4 : "@value": format!("{h:02}:{m:02}:{sec:02}Z"),
1349 : })
1350 : } else {
1351 4 : finite(nums.iter().sum::<f64>() / nums.len() as f64)
1352 : }
1353 : }
1354 0 : "stddev" => {
1355 0 : if nums.is_empty() {
1356 0 : Value::Null
1357 : } else {
1358 0 : let n = nums.len() as f64;
1359 0 : let mean = nums.iter().sum::<f64>() / n;
1360 0 : finite((nums.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n).sqrt())
1361 : }
1362 : }
1363 0 : "sumsq" => finite(nums.iter().map(|v| v * v).sum::<f64>()),
1364 0 : _ => Value::Null,
1365 : }
1366 138 : }
1367 :
1368 : // ---------- GET /temporal/entities/ (5.7.4) ----------
1369 :
1370 610 : pub async fn query_temporal(
1371 610 : State(st): State<AppState>,
1372 610 : CleanParams(params): CleanParams,
1373 610 : headers: HeaderMap,
1374 610 : ) -> Response {
1375 610 : match query_temporal_outer(&st, params, &headers).await {
1376 446 : Ok(r) => r,
1377 164 : Err(e) => e.into_response(),
1378 : }
1379 610 : }
1380 :
1381 : /// 5.7.4.4 EntityMap usage on the temporal query: a live map referenced by
1382 : /// the NGSILD-EntityMap header fixes the result set to the map's Entities
1383 : /// (5.5.14) and its location is echoed; an unknown or expired reference
1384 : /// means "a new EntityMap shall be created" (the entityMap=true branch,
1385 : /// answering 201 + the fresh location).
1386 610 : async fn query_temporal_outer(
1387 610 : st: &AppState,
1388 610 : mut params: HashMap<String, String>,
1389 610 : headers: &HeaderMap,
1390 610 : ) -> ApiResult<Response> {
1391 610 : let tenant = tenant_from(headers)?;
1392 610 : let filter =
1393 610 : gate!(st, &tenant, headers, "5.7.4", scope_q: params.get("scopeQ").map(String::as_str))
1394 610 : .await?;
1395 610 : let Some(map_ref) = single_header(headers, "NGSILD-EntityMap")? else {
1396 594 : return query_temporal_inner(st, ¶ms, headers, &filter).await;
1397 : };
1398 14 : let map_id = map_ref.rsplit('/').next().unwrap_or(&map_ref).to_owned();
1399 14 : let Some(mut map) = crate::entity_map::map_if_accessible(st, &tenant, headers, &map_id).await
1400 : else {
1401 6 : params.insert("entityMap".into(), "true".into());
1402 6 : return query_temporal_inner(st, ¶ms, headers, &filter).await;
1403 : };
1404 8 : params.remove("entityMap");
1405 : // 5.5.9.3: the map fixes the candidate set and the request's own filters
1406 : // narrow it, so `id=` on this request selects from the map rather than
1407 : // replacing it.
1408 8 : let candidates = crate::entity_map::candidate_ids(&map, ¶ms);
1409 : // "filters shall be rechecked before returning results" and "Entities not
1410 : // or no longer fitting the query shall be removed from the Entity map
1411 : // during pagination" — so the recheck asks about the map's OWN Entities,
1412 : // in bounded chunks. Asking the whole Tenant instead judged, and then
1413 : // deleted, entries this request never asked about, and lost every
1414 : // candidate past the first page of the recheck. Pruning is judgeable only
1415 : // for "@none" (local) entries: a remote-backed id may merely have an
1416 : // unreachable source right now (5.5.14). Known cost: this recheck is a
1417 : // second temporal query per map-using request, same shape as the entity
1418 : // query's filter re-run.
1419 8 : let mut matching: std::collections::HashSet<String> = std::collections::HashSet::new();
1420 8 : for chunk in candidates.chunks(st.max_limit.max(1)) {
1421 8 : let mut eff = params.clone();
1422 24 : for k in ["limit", "offset", "count"] {
1423 24 : eff.remove(k);
1424 24 : }
1425 8 : eff.insert("limit".into(), st.max_limit.to_string());
1426 8 : eff.insert("id".into(), chunk.join(","));
1427 8 : let resp = query_temporal_inner(st, &eff, headers, &filter).await?;
1428 8 : let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1429 8 : .await
1430 8 : .map_err(|_| NgsiError::InternalError("entityMap recheck read".into()))?;
1431 8 : matching.extend(
1432 8 : serde_json::from_slice::<Value>(&bytes)
1433 8 : .ok()
1434 8 : .and_then(|v| v.as_array().cloned())
1435 8 : .unwrap_or_default()
1436 8 : .iter()
1437 8 : .filter_map(|d| d.get("id").and_then(Value::as_str).map(str::to_owned)),
1438 : );
1439 : }
1440 8 : if let Some(emap) = map.get_mut("entityMap").and_then(Value::as_object_mut) {
1441 8 : let stale: Vec<String> = candidates
1442 8 : .iter()
1443 8 : .filter(|eid| {
1444 8 : emap.get(eid.as_str())
1445 8 : .and_then(Value::as_array)
1446 8 : .is_some_and(|a| a.len() == 1 && a[0] == "@none")
1447 8 : && !matching.contains(eid.as_str())
1448 8 : })
1449 8 : .cloned()
1450 8 : .collect();
1451 8 : for k in stale {
1452 0 : emap.remove(&k);
1453 0 : }
1454 0 : }
1455 8 : crate::entity_map::map_put(st, &tenant, map.clone()).await?;
1456 : // fix the query to the candidates that survived the recheck (5.5.14)
1457 8 : let ids: Vec<&str> = candidates
1458 8 : .iter()
1459 8 : .filter(|id| map["entityMap"].get(id.as_str()).is_some())
1460 8 : .map(String::as_str)
1461 8 : .collect();
1462 8 : params.insert(
1463 8 : "id".into(),
1464 8 : if ids.is_empty() {
1465 0 : "urn:ngsi-ld:entitymap:empty".to_owned()
1466 : } else {
1467 8 : ids.join(",")
1468 : },
1469 : );
1470 8 : let mut resp = query_temporal_inner(st, ¶ms, headers, &filter).await?;
1471 8 : if let Ok(v) = format!("/ngsi-ld/v1/entityMaps/{map_id}").parse() {
1472 8 : resp.headers_mut().insert("NGSILD-EntityMap", v);
1473 8 : }
1474 8 : Ok(resp)
1475 610 : }
1476 :
1477 : /// The 5.7.4.4 preconditions of a temporal query, before any store is
1478 : /// touched: the filter must qualify, Linked Entity conditions are not
1479 : /// defined for it, a context source filter must parse, and ordering may
1480 : /// name only `id` and only where the execution stays local (4.23.1).
1481 628 : async fn check_temporal_query(
1482 628 : st: &AppState,
1483 628 : params: &HashMap<String, String>,
1484 628 : headers: &HeaderMap,
1485 628 : tenant: &TenantId,
1486 628 : ctx: &Context,
1487 628 : q_ast: Option<&antares_ql::QNode>,
1488 628 : ) -> ApiResult<()> {
1489 628 : let attrs_qualify = params.get("attrs").is_some_and(|a| {
1490 10 : a.split(',')
1491 10 : .any(|n| antares_ql::is_non_system_attr(n.trim()))
1492 10 : });
1493 628 : let q_qualifies = q_ast.is_some_and(|ast| {
1494 220 : ast.attribute_paths()
1495 220 : .iter()
1496 220 : .any(|h| antares_ql::is_non_system_attr(h))
1497 220 : });
1498 628 : let has_filter = params.contains_key("type")
1499 44 : || attrs_qualify
1500 44 : || q_qualifies
1501 44 : || params.contains_key("georel")
1502 44 : || params.get("local").map(String::as_str) == Some("true");
1503 628 : if !has_filter {
1504 8 : return Err(NgsiError::BadRequestData(
1505 8 : "temporal query needs at least one of type, attrs, q, georel (5.7.4)".into(),
1506 8 : )
1507 8 : .into());
1508 620 : }
1509 : // 5.7.4.4: Linked Entity retrieval is not defined for temporal queries —
1510 : // linked filter conditions are an unconditional BadRequestData
1511 620 : if q_ast.map(antares_ql::QNode::max_link_depth).unwrap_or(0) > 0 {
1512 8 : return Err(NgsiError::BadRequestData(
1513 8 : "temporal q must not reference Linked Entity attributes (5.7.4.4)".into(),
1514 8 : )
1515 8 : .into());
1516 612 : }
1517 : // 5.7.4.4: a syntactically invalid context source filter is 400; the
1518 : // filter itself gates registrations in federation::reg_matches.
1519 612 : if let Some(csf) = params.get("csf") {
1520 8 : parse_q(csf)?;
1521 604 : }
1522 604 : crate::paging::check_collation(params)?;
1523 : // 5.7.4.4: temporal ordering may only refer to the "id" entity member,
1524 : // and only where the execution "is limited to the local scope (see
1525 : // clause 5.5.13)" — 4.23.1 gives the reason: "Sort ordering is never
1526 : // applied to distributed operations." The subject is the EXECUTION, so a
1527 : // query nothing would federate to orders without `local=true`.
1528 602 : if let Some(spec) = params.get("orderBy") {
1529 16 : if crate::federation::would_federate(st, tenant, ctx, params, headers).await? {
1530 2 : return Err(NgsiError::BadRequestData(
1531 2 : "orderBy requires local scope — ordering is never applied to \
1532 2 : distributed operations (5.7.4.4, 4.23.1)"
1533 2 : .into(),
1534 2 : )
1535 2 : .into());
1536 14 : }
1537 14 : let non_id = spec.split(',').any(|part| {
1538 14 : let m = part.trim().split(';').next().unwrap_or("").trim();
1539 14 : m.split('[').next().unwrap_or(m) != "id"
1540 14 : });
1541 14 : if non_id {
1542 8 : return Err(NgsiError::BadRequestData(
1543 8 : "temporal orderBy may only name \"id\" (5.7.4.4)".into(),
1544 8 : )
1545 8 : .into());
1546 6 : }
1547 586 : }
1548 592 : Ok(())
1549 628 : }
1550 :
1551 : /// 4.5.19: the aggregated answer of a temporal query. The store computed
1552 : /// the bucket matrix, so the page is presented with the core members the
1553 : /// instance path renders and the aggregated attribute objects copied
1554 : /// under their compacted names.
1555 : #[allow(clippy::too_many_arguments)]
1556 6 : fn respond_aggregated(
1557 6 : st: &AppState,
1558 6 : params: &HashMap<String, String>,
1559 6 : tenant: &TenantId,
1560 6 : ctx: &Context,
1561 6 : accept: Accept,
1562 6 : trepr: &TRepr,
1563 6 : tq: Option<&TemporalQ>,
1564 6 : attrs_filter: Option<&Vec<String>>,
1565 6 : outcome: antares_store::filter::TemporalOutcome,
1566 6 : ) -> ApiResult<Response> {
1567 6 : let total = outcome
1568 6 : .total
1569 6 : .map(|t| t as usize)
1570 6 : .unwrap_or(outcome.rows.len());
1571 6 : let (page, count_hdr, links) = crate::paging::paginate_pre(
1572 6 : st,
1573 6 : params,
1574 6 : outcome.rows,
1575 6 : "/ngsi-ld/v1/temporal/entities",
1576 6 : total,
1577 0 : )?;
1578 6 : let timeprop = tq.map_or("observedAt", |t| t.timeproperty.as_str());
1579 6 : let none = Windowed {
1580 6 : attrs: Default::default(),
1581 6 : max_per_attr: 0,
1582 6 : ts_min: None,
1583 6 : ts_max: None,
1584 6 : truncated: false,
1585 6 : };
1586 6 : let mut payload: Vec<Value> = Vec::new();
1587 8 : for d in &page {
1588 : // core members exactly as the instance path presents them; the
1589 : // aggregated attribute objects are copied under compacted names
1590 8 : let mut presented = present_temporal(d, &none, ctx, trepr, tq, timeprop)?;
1591 8 : let Some(out) = presented.as_object_mut() else {
1592 0 : continue;
1593 : };
1594 8 : let mut any = false;
1595 48 : for (k, v) in d.as_object().into_iter().flatten() {
1596 48 : if is_meta(k) || attrs_filter.is_some_and(|a| !a.contains(k)) {
1597 34 : continue;
1598 14 : }
1599 14 : out.insert(ctx.compact_iri(k), v.clone());
1600 14 : any = true;
1601 : }
1602 8 : if any {
1603 8 : payload.push(presented);
1604 8 : }
1605 : }
1606 6 : let mut resp = crate::negotiate::respond_list(StatusCode::OK, payload, ctx, accept, tenant);
1607 6 : attach_paging(&mut resp, count_hdr, &links);
1608 6 : Ok(resp)
1609 6 : }
1610 :
1611 : /// The two Table 5.2.33-1 selectors that need no compiled pattern: the
1612 : /// type set, and the `attrs` list, which excludes an entity carrying
1613 : /// none of the named Attributes. Each drops an entity the store could
1614 : /// not narrow away itself.
1615 1430 : fn type_and_attrs_match(
1616 1430 : doc: &Value,
1617 1430 : types: Option<&Vec<String>>,
1618 1430 : entity_attr_filter: Option<&Vec<String>>,
1619 1430 : ) -> bool {
1620 1430 : if let Some(types) = types {
1621 1420 : let etypes = doc["type"].as_array().cloned().unwrap_or_default();
1622 1420 : if !etypes
1623 1420 : .iter()
1624 1420 : .any(|t| types.iter().any(|w| Some(w.as_str()) == t.as_str()))
1625 : {
1626 0 : return false;
1627 1420 : }
1628 10 : }
1629 1430 : if let Some(want) = entity_attr_filter {
1630 0 : if !want.iter().any(|a| doc.get(a).is_some()) {
1631 0 : return false;
1632 0 : }
1633 1430 : }
1634 1430 : true
1635 1430 : }
1636 :
1637 : /// 5.7.4.4 S2/S3: the values filter and the geoquery are judged on the
1638 : /// Attribute instances WITHIN the temporal-query interval, so an
1639 : /// out-of-window instance must not satisfy either.
1640 910 : fn window_conditions_match(
1641 910 : doc: &Value,
1642 910 : tq: Option<&TemporalQ>,
1643 910 : q_ast: Option<&antares_ql::QNode>,
1644 910 : geo: Option<&antares_ql::geo::GeoQuery>,
1645 910 : ctx: &Context,
1646 910 : ) -> bool {
1647 : // 5.7.4.4 S2/S3: the values filter and geoquery are checked against
1648 : // the Attribute instances WITHIN the temporal-query interval — an
1649 : // out-of-window instance must not satisfy them
1650 910 : let mut eval_doc = doc.clone();
1651 910 : if let (Some(tqv), Some(o)) = (tq, eval_doc.as_object_mut()) {
1652 4960 : for (k, v) in o.iter_mut() {
1653 4960 : if is_meta(k) {
1654 3632 : continue;
1655 1328 : }
1656 1328 : if let Some(arr) = v.as_array_mut() {
1657 1470 : arr.retain(|inst| tqv.instance_matches(inst));
1658 0 : }
1659 : }
1660 0 : }
1661 910 : if let Some(ast) = q_ast {
1662 820 : if !antares_ql::eval::eval_q(ast, &eval_doc, ctx, &|_| None) {
1663 606 : return false;
1664 214 : }
1665 90 : }
1666 304 : if let Some(g) = geo {
1667 90 : if !g.matches(&eval_doc, ctx) {
1668 58 : return false;
1669 32 : }
1670 214 : }
1671 246 : true
1672 910 : }
1673 :
1674 : /// 5.7.4.4 S4 — and S7: the federation merge precedes this check, so
1675 : /// split and aggregated entities are re-filtered here too. The entity
1676 : /// qualifies if one of its scope's 4.18 validity intervals intersects
1677 : /// the query window; attribute instances outside every matching interval
1678 : /// are excluded (annex C.5.16).
1679 368 : fn scope_window_retain(doc: &mut Value, sq: &str, tq: Option<&TemporalQ>, timeprop: &str) -> bool {
1680 368 : let iv = scope_match_intervals(doc, sq);
1681 368 : let (wstart, wend) = match tq {
1682 368 : Some(t) => match t.timerel.as_str() {
1683 368 : "before" => (None, Some(t.time_at.as_str())),
1684 368 : "after" => (Some(t.time_at.as_str()), None),
1685 362 : "between" => (Some(t.time_at.as_str()), t.end_time_at.as_deref()),
1686 0 : _ => (None, None),
1687 : },
1688 0 : None => (None, None),
1689 : };
1690 368 : let intersects = iv.iter().any(|(s, e)| {
1691 106 : wend.is_none_or(|w| s.as_str() < w)
1692 96 : && e.as_deref().is_none_or(|e| wstart.is_none_or(|w| e > w))
1693 106 : });
1694 368 : if !intersects {
1695 272 : return false;
1696 96 : }
1697 96 : if let Some(o) = doc.as_object_mut() {
1698 576 : for (k, v) in o.iter_mut() {
1699 576 : if is_meta(k) {
1700 480 : continue;
1701 96 : }
1702 96 : if let Some(arr) = v.as_array_mut() {
1703 130 : arr.retain(|inst| {
1704 130 : inst.get(timeprop).and_then(Value::as_str).is_some_and(|t| {
1705 130 : iv.iter()
1706 134 : .any(|(s, e)| t >= s.as_str() && e.as_deref().is_none_or(|e| t < e))
1707 130 : })
1708 130 : });
1709 0 : }
1710 : }
1711 0 : }
1712 96 : true
1713 368 : }
1714 :
1715 700 : pub(crate) async fn query_temporal_inner(
1716 700 : st: &AppState,
1717 700 : params: &HashMap<String, String>,
1718 700 : headers: &HeaderMap,
1719 700 : filter: &crate::policy::Filter,
1720 700 : ) -> ApiResult<Response> {
1721 700 : query_temporal_collected(st, params, headers, filter, None).await
1722 700 : }
1723 :
1724 : /// The same query, with the merged documents of each page handed to `merged`
1725 : /// as well as rendered into the response. 5.16.1.4 fills a Snapshot from this
1726 : /// query and has to STORE what it matched: for an Evolution held by a
1727 : /// registered Context Source there is nothing else in this broker to store —
1728 : /// the fan-out's answer is the only copy, and the response body is a
1729 : /// presentation of it that compaction does not undo.
1730 712 : pub(crate) async fn query_temporal_collected(
1731 712 : st: &AppState,
1732 712 : params: &HashMap<String, String>,
1733 712 : headers: &HeaderMap,
1734 712 : filter: &crate::policy::Filter,
1735 712 : merged: Option<&mut Vec<Value>>,
1736 712 : ) -> ApiResult<Response> {
1737 712 : let tenant = tenant_from(headers)?;
1738 712 : check_params(
1739 712 : params,
1740 712 : &[
1741 712 : "id",
1742 712 : "idPattern",
1743 712 : "type",
1744 712 : "attrs",
1745 712 : "q",
1746 712 : "georel",
1747 712 : "geometry",
1748 712 : "coordinates",
1749 712 : "geoproperty",
1750 712 : "scopeQ",
1751 712 : "csf",
1752 712 : "timerel",
1753 712 : "timeAt",
1754 712 : "endTimeAt",
1755 712 : "timeproperty",
1756 712 : "aggrMethods",
1757 712 : "aggrPeriodDuration",
1758 712 : "lastN",
1759 712 : "limit",
1760 712 : "offset",
1761 712 : "count",
1762 712 : "options",
1763 712 : "format",
1764 712 : "lang",
1765 712 : "local",
1766 712 : "entityMap",
1767 712 : "pick",
1768 712 : "omit",
1769 712 : "datasetId",
1770 712 : "orderBy",
1771 712 : "orderFrom",
1772 712 : "orderGeometry",
1773 712 : "collation",
1774 712 : "entityMapLifetime",
1775 712 : "splitEntities",
1776 712 : "expandValues",
1777 712 : "jsonKeys",
1778 712 : ],
1779 4 : )?;
1780 708 : let accept = parse_accept(headers)?;
1781 700 : let ctx = request_context(&st.loader, headers).await?;
1782 : // 5.7.4.4 a-e: id/idPattern alone are NOT sufficient, and the attrs
1783 : // list / q must include at least one non-system Attribute to qualify.
1784 : // 5.7.4.3 expandValues: the same 4.9 EXAMPLE 12 coercion as the entity
1785 : // query — term values expanded against the @context before executing,
1786 : // less the Attributes jsonKeys declares uninterpretable.
1787 628 : let q_ast = params.get("q").map(|q| parse_q(q)).transpose()?.map(|ast| {
1788 220 : antares_ql::eval::apply_expand_values(
1789 220 : ast,
1790 220 : antares_ql::eval::expansion_list(
1791 220 : params.get("expandValues").map(String::as_str),
1792 220 : params.get("jsonKeys").map(String::as_str),
1793 220 : )
1794 220 : .as_deref(),
1795 220 : &ctx,
1796 : )
1797 220 : });
1798 628 : check_temporal_query(st, params, headers, &tenant, &ctx, q_ast.as_ref()).await?;
1799 : // ADR-0020: the engine's narrowing joins the request's own filters after
1800 : // 5.7.4.4's "too wide" judgement, which is about what the client asked
1801 : // for. Everything below reads the narrowed query, the forward included.
1802 592 : let narrowed = filter.narrow_params(params)?;
1803 592 : let params = &narrowed;
1804 592 : let q_ast = params.get("q").map(|q| parse_q(q)).transpose()?.map(|ast| {
1805 212 : antares_ql::eval::apply_expand_values(
1806 212 : ast,
1807 212 : antares_ql::eval::expansion_list(
1808 212 : params.get("expandValues").map(String::as_str),
1809 212 : params.get("jsonKeys").map(String::as_str),
1810 212 : )
1811 212 : .as_deref(),
1812 212 : &ctx,
1813 : )
1814 212 : });
1815 592 : let scope_q = params.get("scopeQ").map(String::as_str);
1816 592 : let tq = TemporalQ::from_params(params, true)?;
1817 576 : let mut trepr = parse_trepr(params, &ctx)?;
1818 544 : crate::repr::narrow_projection(&mut trepr.pick, &mut trepr.omit, filter, &ctx)?;
1819 : // 5.7.4.4: {…} projection is Linked Entity retrieval — unconditional 400
1820 544 : reject_linked_projection(&trepr, "5.7.4.4")?;
1821 532 : let last_n = trepr.last_n;
1822 :
1823 532 : let ids: Option<Vec<&str>> = params.get("id").map(|s| s.split(',').collect());
1824 : // 5.7.4.4: an invalid URI in the id list is BadRequestData
1825 532 : if let Some(ids) = &ids {
1826 56 : for id in ids {
1827 56 : antares_model::EntityId::new(id)?;
1828 : }
1829 476 : }
1830 524 : let id_pattern = match params.get("idPattern") {
1831 0 : Some(p) => Some(
1832 0 : antares_ql::regex::compile(p)
1833 0 : .map_err(|_| NgsiError::BadRequestData(format!("invalid idPattern {p:?}")))?,
1834 : ),
1835 524 : None => None,
1836 : };
1837 524 : let types: Option<Vec<String>> = params.get("type").map(|s| {
1838 488 : s.split([',', '|'])
1839 488 : .map(|t| ctx.expand_key(t.trim()))
1840 488 : .collect()
1841 488 : });
1842 524 : let attrs_filter = selection(&trepr);
1843 : // only the attrs= param excludes entities; pick is projection-only
1844 524 : let entity_attr_filter = trepr.attrs.clone();
1845 524 : let geo = antares_ql::geo::GeoQuery::from_params(params)?;
1846 :
1847 : // Push entity narrowing (ids/types/attrs) and instance-window
1848 : // pruning (range + RANK()-capped lastN) into the store. The loop below
1849 : // and window() stay the arbiters — pruning is byte-exact against
1850 : // instance_matches (compile::temporal), so it cannot change an answer.
1851 : // 5.7.4.4 S2/S3: q and geo are judged on the instances WITHIN the
1852 : // temporal interval (the eval_doc retain below), so RANGE pruning is
1853 : // verdict-safe to push even with q=/geo present. Only the lastN cap
1854 : // (its ordering vs the values filter is unspecified) and entity paging
1855 : // (q/geo still drop entities after SQL) wait for exactness.
1856 : // scopeQ joins q/geo here: the 4.18 validity filter drops entities and
1857 : // instances AFTER SQL, so a pushed page/lastN cap would under-return.
1858 524 : let exact_push = q_ast.is_none() && geo.is_none() && scope_q.is_none();
1859 : // Entity-page pushdown: a temporal query used to materialize the
1860 : // tenant's ENTIRE history. Pushed only when every filter the store
1861 : // cannot see is absent — same gate family as the entity-query pushdown.
1862 : // A values filter no longer blocks paging when its prefilter compiles
1863 : // EXACTLY (every leaf a Cmp with the byte-exact text window): the SQL
1864 : // entity verdict then equals the evaluator's. datasetId/pick still
1865 : // block: their entity drops happen at presentation, after the page.
1866 524 : let q_page_exact = q_ast.as_ref().is_none_or(|ast| {
1867 212 : let r = tq.as_ref().map(|t| antares_store::filter::InstanceRange {
1868 212 : timerel: &t.timerel,
1869 212 : time_at: &t.time_at,
1870 212 : end_time_at: t.end_time_at.as_deref(),
1871 212 : timeproperty: &t.timeproperty,
1872 212 : });
1873 212 : st.temporal
1874 212 : .q_pushdown_exact(ast, r.as_ref(), &|t| ctx.expand_key(t))
1875 212 : });
1876 524 : let (p_offset, p_limit, _) = crate::paging::page_params(st, params)?;
1877 : // 5.7.4.4 + 5.5.9: pagination applies to the MERGED federated union, so
1878 : // the store may only pre-page when nothing will federate — otherwise
1879 : // page 1 is local-page + every remote row (matrix-9 IOP_EXT_TMP_03_04).
1880 524 : let push_page = (exact_push || (geo.is_none() && scope_q.is_none() && q_page_exact))
1881 254 : && id_pattern.is_none()
1882 254 : && params.get("orderBy").is_none()
1883 248 : && params.get("datasetId").is_none()
1884 248 : && params.get("pick").is_none()
1885 246 : && p_limit > 0
1886 246 : && !crate::federation::would_federate(st, &tenant, &ctx, params, headers).await?;
1887 : // 4.5.19 computed by the store: the numeric bucket matrix per attribute
1888 : // comes back aggregated when nothing after the store call could change
1889 : // the answer — every filter exact in SQL, the page pushed, no
1890 : // projection/lastN/month periods — otherwise the instances are
1891 : // aggregated here as before. A store that cannot (memory) or a
1892 : // non-numeric value class leaves `outcome.aggregated` false.
1893 524 : let push_agg = trepr.aggregated
1894 26 : && exact_push
1895 26 : && push_page
1896 26 : && trepr.omit.is_none()
1897 26 : && last_n.is_none()
1898 26 : && !matches!(trepr.aggr_period, AggrPeriod::Months(..))
1899 26 : && params.get("entityMap").map(String::as_str) != Some("true")
1900 26 : && trepr
1901 26 : .aggr_methods
1902 26 : .iter()
1903 48 : .all(|m| antares_store::filter::AGGREGATE_METHODS.contains(&m.as_str()));
1904 : // scoped: the &dyn expander must not live across an await (handler
1905 : // futures are Send; the store call itself is synchronous)
1906 522 : let outcome = {
1907 524 : let expand = |t: &str| ctx.expand_key(t);
1908 524 : let geo_pre = geo.as_ref().map(|g| g.to_instance_spec(&ctx));
1909 524 : let tf = antares_store::filter::TemporalFilter {
1910 524 : ids: ids.as_deref(),
1911 524 : types: types.as_deref(),
1912 524 : attrs: entity_attr_filter.as_deref(),
1913 524 : range: tq.as_ref().map(|t| antares_store::filter::InstanceRange {
1914 524 : timerel: &t.timerel,
1915 524 : time_at: &t.time_at,
1916 524 : end_time_at: t.end_time_at.as_deref(),
1917 524 : timeproperty: &t.timeproperty,
1918 524 : }),
1919 524 : last_n: match (last_n, exact_push) {
1920 8 : (Some(n), true) => Some(n as i64),
1921 516 : _ => None,
1922 : },
1923 524 : timeproperty: tq
1924 524 : .as_ref()
1925 524 : .map_or("observedAt", |t| t.timeproperty.as_str()),
1926 524 : page: push_page.then_some(antares_store::filter::Page {
1927 524 : offset: p_offset as i64,
1928 524 : limit: p_limit as i64,
1929 524 : count: true,
1930 524 : }),
1931 524 : q: q_ast.as_ref(),
1932 524 : expand: &expand,
1933 524 : geo: geo_pre.as_ref().map(|(s, iri)| (s, iri.as_str())),
1934 524 : aggregate: push_agg.then_some(antares_store::filter::Aggregate {
1935 524 : methods: &trepr.aggr_methods,
1936 524 : period_secs: match trepr.aggr_period {
1937 4 : AggrPeriod::Seconds(sc) => Some(sc),
1938 520 : _ => None,
1939 : },
1940 524 : anchor: tq.as_ref().map(|t| t.time_at.as_str()),
1941 : }),
1942 : };
1943 524 : st.temporal.query_temporal(&tenant, &tf).await?
1944 : };
1945 522 : if outcome.aggregated {
1946 6 : return respond_aggregated(
1947 6 : st,
1948 6 : params,
1949 6 : &tenant,
1950 6 : &ctx,
1951 6 : accept,
1952 6 : &trepr,
1953 6 : tq.as_ref(),
1954 6 : attrs_filter.as_ref(),
1955 6 : outcome,
1956 : );
1957 516 : }
1958 516 : let (all, pre_paged, pre_total) = (outcome.rows, outcome.paged, outcome.total);
1959 : // 5.7.4.4: fan the query out to matching queryTemporal registrations
1960 : // and merge the remote Temporal Evolutions with the local set (4.5.5;
1961 : // auxiliary data never introduces new entities)
1962 516 : let mut warnings: Vec<String> = Vec::new();
1963 516 : let looped = crate::federation::via_loop(
1964 516 : headers,
1965 516 : &crate::federation::alias_for(&st.host_alias, &tenant),
1966 : );
1967 516 : let timeprop = tq
1968 516 : .as_ref()
1969 516 : .map_or("observedAt", |t| t.timeproperty.as_str())
1970 516 : .to_owned();
1971 516 : let all = if crate::federation::active(params) && !looped {
1972 478 : let fed = crate::federation::fed_query_temporal(
1973 478 : st,
1974 478 : &tenant,
1975 478 : headers,
1976 478 : &ctx,
1977 478 : params,
1978 478 : &mut warnings,
1979 478 : )
1980 478 : .await?;
1981 478 : let mut order: Vec<String> = Vec::new();
1982 478 : let mut by_id: std::collections::HashMap<String, Value> = Default::default();
1983 1442 : for doc in all {
1984 1442 : if let Some(id) = doc.get("id").and_then(Value::as_str) {
1985 1442 : order.push(id.to_owned());
1986 1442 : by_id.insert(id.to_owned(), doc);
1987 1442 : }
1988 : }
1989 956 : for aux_pass in [false, true] {
1990 956 : for (aux, d) in &fed {
1991 20 : if *aux != aux_pass {
1992 10 : continue;
1993 10 : }
1994 10 : let Some(id) = d.get("id").and_then(Value::as_str) else {
1995 0 : continue;
1996 : };
1997 10 : match by_id.get_mut(id) {
1998 0 : Some(base) => merge_temporal_docs(base, d, *aux, &timeprop),
1999 10 : None if !aux => {
2000 10 : order.push(id.to_owned());
2001 10 : by_id.insert(id.to_owned(), d.clone());
2002 10 : }
2003 0 : None => {}
2004 : }
2005 : }
2006 : }
2007 478 : order
2008 478 : .into_iter()
2009 1452 : .filter_map(|id| by_id.remove(&id))
2010 478 : .collect()
2011 : } else {
2012 38 : all
2013 : };
2014 516 : let mut matches = Vec::new();
2015 1462 : for mut doc in all {
2016 1462 : let id = doc["id"].as_str().unwrap_or("");
2017 1462 : if let Some(ids) = &ids {
2018 80 : if !ids.contains(&id) {
2019 32 : continue;
2020 48 : }
2021 1382 : }
2022 : // 5.2.33: "id takes precedence over idPattern"
2023 1430 : if ids.is_none() {
2024 1382 : if let Some(re) = &id_pattern {
2025 0 : if !re.is_match(id) {
2026 0 : continue;
2027 0 : }
2028 1382 : }
2029 48 : }
2030 1430 : if !type_and_attrs_match(&doc, types.as_ref(), entity_attr_filter.as_ref()) {
2031 0 : continue;
2032 1430 : }
2033 1430 : if (q_ast.is_some() || geo.is_some())
2034 910 : && !window_conditions_match(&doc, tq.as_ref(), q_ast.as_ref(), geo.as_ref(), &ctx)
2035 : {
2036 664 : continue;
2037 766 : }
2038 766 : if let Some(sq) = scope_q {
2039 368 : if !scope_window_retain(&mut doc, sq, tq.as_ref(), timeprop.as_str()) {
2040 272 : continue;
2041 96 : }
2042 398 : }
2043 : // entity qualifies only if some instance falls in the window
2044 494 : let any_instance = doc.as_object().is_some_and(|o| {
2045 1020 : o.iter().any(|(k, v)| {
2046 1020 : !is_meta(k)
2047 494 : && v.as_array().is_some_and(|arr| {
2048 494 : arr.iter()
2049 498 : .any(|inst| tq.as_ref().is_none_or(|tq| tq.instance_matches(inst)))
2050 494 : })
2051 1020 : })
2052 494 : });
2053 494 : if !any_instance {
2054 14 : continue;
2055 480 : }
2056 480 : matches.push(doc);
2057 : }
2058 516 : if let Some(spec) = params.get("orderBy") {
2059 6 : crate::paging::order_entities(&mut matches, spec, params, &ctx)?;
2060 510 : }
2061 516 : let (page, count_hdr, links) = if pre_paged {
2062 90 : let total = pre_total.map(|t| t as usize).unwrap_or(matches.len());
2063 90 : crate::paging::paginate_pre(st, params, matches, "/ngsi-ld/v1/temporal/entities", total)?
2064 : } else {
2065 426 : crate::paging::paginate(st, params, matches, "/ngsi-ld/v1/temporal/entities")?
2066 : };
2067 516 : if let Some(sink) = merged {
2068 12 : sink.extend(page.iter().cloned());
2069 504 : }
2070 516 : let core_only_pick = attrs_filter.as_ref().is_some_and(Vec::is_empty);
2071 516 : let mut payload: Vec<Value> = Vec::new();
2072 516 : let (mut g_trunc, mut g_min, mut g_maxts) = (false, None::<String>, None::<String>);
2073 516 : for d in &page {
2074 458 : let mut w = window(
2075 458 : d,
2076 458 : tq.as_ref(),
2077 458 : last_n,
2078 458 : attrs_filter.as_ref(),
2079 458 : trepr.omit.as_ref(),
2080 458 : trepr.dataset_id.as_ref(),
2081 458 : &timeprop,
2082 : );
2083 458 : if !trepr.aggregated {
2084 458 : truncate(&mut w, &timeprop, last_n.is_some());
2085 458 : }
2086 458 : g_trunc |= w.truncated;
2087 458 : if let Some(m) = &w.ts_min {
2088 458 : if g_min.as_deref().is_none_or(|c| dt_key(m) < dt_key(c)) {
2089 356 : g_min = Some(m.clone());
2090 356 : }
2091 0 : }
2092 458 : if let Some(m) = &w.ts_max {
2093 458 : if g_maxts.as_deref().is_none_or(|c| dt_key(m) > dt_key(c)) {
2094 376 : g_maxts = Some(m.clone());
2095 376 : }
2096 0 : }
2097 : // no instance survived the window/dataset filters ⇒ the entity is
2098 : // not part of the temporal result (unless the projection is
2099 : // deliberately core-only, e.g. pick=id)
2100 458 : if w.attrs.is_empty() && !core_only_pick {
2101 0 : continue;
2102 458 : }
2103 458 : let presented = present_temporal(d, &w, &ctx, &trepr, tq.as_ref(), &timeprop)?;
2104 458 : if trepr.pick.is_some() && presented.as_object().is_some_and(|o| o.is_empty()) {
2105 0 : continue;
2106 458 : }
2107 458 : payload.push(presented);
2108 : }
2109 : // aggregated responses are complete by construction — never 206 (6.3.10)
2110 516 : let cr = if trepr.aggregated {
2111 20 : None
2112 : } else {
2113 496 : content_range(
2114 496 : g_trunc,
2115 496 : g_min.as_deref(),
2116 496 : g_maxts.as_deref(),
2117 496 : tq.as_ref(),
2118 496 : last_n,
2119 : )
2120 : };
2121 516 : let status = if cr.is_some() {
2122 2 : StatusCode::PARTIAL_CONTENT
2123 : } else {
2124 514 : StatusCode::OK
2125 : };
2126 516 : let mut resp = crate::negotiate::respond_list(status, payload, &ctx, accept, &tenant);
2127 516 : crate::paging::attach_warnings(&mut resp, &warnings);
2128 516 : if let Some(cr) = cr {
2129 2 : if let Ok(v) = cr.parse() {
2130 2 : resp.headers_mut().insert("Content-Range", v);
2131 2 : }
2132 514 : }
2133 516 : attach_paging(&mut resp, count_hdr, &links);
2134 : // 6.18.3.2: entityMap=true — the temporal EntityMap for this query is
2135 : // (re)created; the response carries NGSILD-EntityMap and 201 Created.
2136 516 : if params.get("entityMap").map(String::as_str) == Some("true") {
2137 12 : let map = build_temporal_map(st, &tenant, headers, &ctx, params, filter).await?;
2138 12 : *resp.status_mut() = StatusCode::CREATED;
2139 12 : if let Some(id) = map.get("id").and_then(Value::as_str) {
2140 12 : if let Ok(v) = format!("/ngsi-ld/v1/entityMaps/{id}").parse() {
2141 12 : resp.headers_mut().insert("NGSILD-EntityMap", v);
2142 12 : }
2143 0 : }
2144 504 : }
2145 516 : filter.mark_restricted(resp.headers_mut());
2146 516 : Ok(resp)
2147 712 : }
2148 :
2149 : // ---------- GET /temporal/entities/{id} (5.7.3) ----------
2150 :
2151 284 : pub async fn retrieve_temporal(
2152 284 : State(st): State<AppState>,
2153 284 : Path(id): Path<String>,
2154 284 : CleanParams(params): CleanParams,
2155 284 : headers: HeaderMap,
2156 284 : ) -> Response {
2157 284 : match retrieve_temporal_outer(&st, &id, ¶ms, &headers).await {
2158 204 : Ok(r) => r,
2159 80 : Err(e) => e.into_response(),
2160 : }
2161 284 : }
2162 :
2163 : /// 5.7.3.4 Retrieve Temporal Evolution: the EntityMap half of the clause is
2164 : /// the shared rule (`entity_maps::retrieve_with_map`); this is the retrieve
2165 : /// it wraps.
2166 284 : async fn retrieve_temporal_outer(
2167 284 : st: &AppState,
2168 284 : id: &str,
2169 284 : params: &HashMap<String, String>,
2170 284 : headers: &HeaderMap,
2171 284 : ) -> ApiResult<Response> {
2172 284 : crate::entity_map::retrieve_with_map(st, id, params, headers, true, |map| async move {
2173 284 : retrieve_temporal_inner(st, id, params, headers, map.as_ref()).await
2174 568 : })
2175 284 : .await
2176 284 : }
2177 :
2178 284 : async fn retrieve_temporal_inner(
2179 284 : st: &AppState,
2180 284 : id: &str,
2181 284 : params: &HashMap<String, String>,
2182 284 : headers: &HeaderMap,
2183 284 : map: Option<&Value>,
2184 284 : ) -> ApiResult<Response> {
2185 : {
2186 284 : let tenant = tenant_from(headers)?;
2187 284 : check_params(
2188 284 : params,
2189 284 : &[
2190 284 : "attrs",
2191 284 : "timerel",
2192 284 : "timeAt",
2193 284 : "endTimeAt",
2194 284 : "timeproperty",
2195 284 : "lastN",
2196 284 : "aggrMethods",
2197 284 : "aggrPeriodDuration",
2198 284 : "options",
2199 284 : "format",
2200 284 : "lang",
2201 284 : "local",
2202 284 : "pick",
2203 284 : "omit",
2204 284 : "datasetId",
2205 284 : "entityMap",
2206 284 : "entityMapLifetime",
2207 284 : ],
2208 0 : )?;
2209 284 : let accept = parse_accept(headers)?;
2210 284 : let ctx = request_context(&st.loader, headers).await?;
2211 268 : let filter = gate!(st, &tenant, headers, "5.7.3", ids: &[id]).await?;
2212 268 : let tq = TemporalQ::from_params(params, false)?;
2213 268 : let mut trepr = parse_trepr(params, &ctx)?;
2214 264 : crate::repr::narrow_projection(&mut trepr.pick, &mut trepr.omit, &filter, &ctx)?;
2215 : // 5.7.3.4: "If projection attributes are present and indicate the
2216 : // use of Linked Entity retrieval, an error of type BadRequestData
2217 : // shall be raised" — unconditional, temporal defines no join.
2218 264 : reject_linked_projection(&trepr, "5.7.3.4")?;
2219 252 : let last_n = trepr.last_n;
2220 252 : antares_model::EntityId::new(id)?;
2221 : // Instance pruning pushed into the store (no q=/geo on retrieve,
2222 : // so it is always safe here); window() below stays the arbiter.
2223 248 : let tf = antares_store::filter::TemporalFilter {
2224 248 : range: tq.as_ref().map(|t| antares_store::filter::InstanceRange {
2225 116 : timerel: &t.timerel,
2226 116 : time_at: &t.time_at,
2227 116 : end_time_at: t.end_time_at.as_deref(),
2228 116 : timeproperty: &t.timeproperty,
2229 116 : }),
2230 248 : last_n: last_n.map(|n| n as i64),
2231 248 : timeproperty: tq
2232 248 : .as_ref()
2233 248 : .map_or("observedAt", |t| t.timeproperty.as_str()),
2234 248 : ..Default::default()
2235 : };
2236 248 : let timeprop = tq
2237 248 : .as_ref()
2238 248 : .map_or("observedAt", |t| t.timeproperty.as_str())
2239 248 : .to_owned();
2240 248 : let local = st.temporal.get_temporal(&tenant, id, &tf).await?;
2241 : // 5.7.3.4: forward to matching retrieveTemporal registrations and
2242 : // merge the remote instance data (4.5.5; auxiliary instances only
2243 : // fill timestamps absent elsewhere)
2244 246 : let mut warnings: Vec<String> = Vec::new();
2245 246 : let looped = crate::federation::via_loop(
2246 246 : headers,
2247 246 : &crate::federation::alias_for(&st.host_alias, &tenant),
2248 : );
2249 246 : let doc = if crate::federation::active(params) && !looped {
2250 246 : let fed = crate::federation::fed_retrieve_temporal(
2251 246 : st,
2252 246 : &tenant,
2253 246 : headers,
2254 246 : &ctx,
2255 246 : id,
2256 246 : params,
2257 246 : map,
2258 246 : &mut warnings,
2259 246 : )
2260 246 : .await?;
2261 246 : let (mut base, skip) = match local {
2262 210 : Some(b) => (b, None),
2263 : None => {
2264 36 : let idx = fed.iter().position(|(aux, _)| !aux).or(if fed.is_empty() {
2265 34 : None
2266 : } else {
2267 2 : Some(0)
2268 : });
2269 36 : match idx {
2270 2 : Some(i) => (fed[i].1.clone(), Some(i)),
2271 : None => {
2272 34 : return Err(NgsiError::ResourceNotFound(format!(
2273 34 : "temporal entity {id} not found"
2274 34 : ))
2275 34 : .into())
2276 : }
2277 : }
2278 : }
2279 : };
2280 424 : for aux_pass in [false, true] {
2281 424 : for (i, (aux, d)) in fed.iter().enumerate() {
2282 4 : if Some(i) == skip {
2283 4 : continue;
2284 0 : }
2285 0 : if *aux == aux_pass {
2286 0 : merge_temporal_docs(&mut base, d, *aux, &timeprop);
2287 0 : }
2288 : }
2289 : }
2290 212 : base
2291 : } else {
2292 0 : local.ok_or_else(|| {
2293 0 : NgsiError::ResourceNotFound(format!("temporal entity {id} not found"))
2294 0 : })?
2295 : };
2296 212 : let attrs_filter = selection(&trepr);
2297 : // 5.7.3: attrs matching nothing ⇒ 404. A `pick` may name core members
2298 : // instead — 5.7.3.3 admits "id", "type", "scope" or an Attribute name
2299 : // — and then it selects no Attribute at all. 5.7.3.5 still reduces the
2300 : // Entity to the members it names, so that empty selection is the
2301 : // answer rather than a Temporal Evolution holding none of it.
2302 212 : let core_only_pick = trepr.attrs.is_none()
2303 202 : && trepr.pick.is_some()
2304 10 : && attrs_filter.as_ref().is_some_and(Vec::is_empty);
2305 212 : if let Some(want) = &attrs_filter {
2306 20 : if !core_only_pick && !want.iter().any(|a| doc.get(a).is_some()) {
2307 2 : return Err(NgsiError::ResourceNotFound(format!(
2308 2 : "temporal entity {id} has none of the requested attributes"
2309 2 : ))
2310 2 : .into());
2311 18 : }
2312 192 : }
2313 210 : let mut w = window(
2314 210 : &doc,
2315 210 : tq.as_ref(),
2316 210 : last_n,
2317 210 : attrs_filter.as_ref(),
2318 210 : trepr.omit.as_ref(),
2319 210 : trepr.dataset_id.as_ref(),
2320 210 : &timeprop,
2321 : );
2322 210 : if !trepr.aggregated {
2323 192 : truncate(&mut w, &timeprop, last_n.is_some());
2324 192 : }
2325 210 : let cr = if trepr.aggregated {
2326 18 : None
2327 : } else {
2328 192 : content_range(
2329 192 : w.truncated,
2330 192 : w.ts_min.as_deref(),
2331 192 : w.ts_max.as_deref(),
2332 192 : tq.as_ref(),
2333 192 : last_n,
2334 : )
2335 : };
2336 210 : let payload = present_temporal(&doc, &w, &ctx, &trepr, tq.as_ref(), &timeprop)?;
2337 204 : if (trepr.pick.is_some() || trepr.omit.is_some())
2338 10 : && payload.as_object().is_some_and(|o| o.is_empty())
2339 : {
2340 0 : return Err(NgsiError::ResourceNotFound(format!(
2341 0 : "projection matches nothing on temporal entity {id}"
2342 0 : ))
2343 0 : .into());
2344 204 : }
2345 204 : let status = if cr.is_some() {
2346 0 : StatusCode::PARTIAL_CONTENT
2347 : } else {
2348 204 : StatusCode::OK
2349 : };
2350 204 : let mut resp = respond(status, payload, &ctx, accept, &tenant);
2351 204 : if let Some(cr) = cr {
2352 0 : if let Ok(v) = cr.parse() {
2353 0 : resp.headers_mut().insert("Content-Range", v);
2354 0 : }
2355 204 : }
2356 204 : crate::paging::attach_warnings(&mut resp, &warnings);
2357 204 : filter.mark_restricted(resp.headers_mut());
2358 204 : Ok(resp)
2359 : }
2360 284 : }
2361 :
2362 : /// 5.7.3.4 / 4.5.5: merge one remote Temporal Evolution into `base` by
2363 : /// appending instances per Attribute; auxiliary data contributes an
2364 : /// instance only when no instance with the same timeproperty value was
2365 : /// received from elsewhere.
2366 20 : fn merge_temporal_docs(base: &mut Value, add: &Value, aux: bool, timeprop: &str) {
2367 20 : let (Some(target), Some(source)) = (base.as_object_mut(), add.as_object()) else {
2368 0 : return;
2369 : };
2370 60 : for (k, v) in source {
2371 60 : if [
2372 60 : "id",
2373 60 : "type",
2374 60 : "scope",
2375 60 : "createdAt",
2376 60 : "modifiedAt",
2377 60 : "deletedAt",
2378 60 : "expiresAt",
2379 60 : ]
2380 60 : .contains(&k.as_str())
2381 : {
2382 40 : target.entry(k.clone()).or_insert_with(|| v.clone());
2383 40 : continue;
2384 20 : }
2385 20 : let incoming: Vec<Value> = match v {
2386 20 : Value::Array(x) => x.clone(),
2387 0 : other => vec![other.clone()],
2388 : };
2389 20 : match target.get_mut(k).and_then(Value::as_array_mut) {
2390 0 : None => {
2391 0 : target.insert(k.clone(), Value::Array(incoming));
2392 0 : }
2393 20 : Some(cur) => {
2394 32 : for ni in incoming {
2395 32 : if aux {
2396 12 : let ts = ni.get(timeprop).and_then(Value::as_str).map(dt_key);
2397 12 : if ts.is_some()
2398 12 : && cur.iter().any(|ci| {
2399 12 : ci.get(timeprop).and_then(Value::as_str).map(dt_key) == ts
2400 12 : })
2401 : {
2402 8 : continue;
2403 4 : }
2404 20 : }
2405 : // 4.5.5.3: an instance with the same datasetId (or both
2406 : // default) AND the same timeproperty value is a
2407 : // CONFLICTING instance of one slot — resolve to one, the
2408 : // most recent modifiedAt winning.
2409 : // Two instances only share a slot when they carry the SAME
2410 : // timeproperty value. Without the is_some() guard a pair of
2411 : // instances that both lack it would compare None == None,
2412 : // letting a remote instance replace unrelated local history.
2413 : // 4.6.3 leaves the seconds fraction optional, so two
2414 : // Context Sources may spell one instant differently: the
2415 : // slot and its winner are decided on the canonical key,
2416 : // or the same instance comes back twice.
2417 24 : let ni_ts = ni.get(timeprop).and_then(Value::as_str).map(dt_key);
2418 24 : let slot = ni_ts.and_then(|ts| {
2419 28 : cur.iter_mut().find(|ci| {
2420 28 : ci.get(timeprop).and_then(Value::as_str).map(dt_key) == Some(ts.clone())
2421 16 : && ci.get("datasetId") == ni.get("datasetId")
2422 28 : })
2423 24 : });
2424 24 : if let Some(existing) = slot {
2425 24 : let stamp = |i: &Value| {
2426 24 : dt_key(i.get("modifiedAt").and_then(Value::as_str).unwrap_or(""))
2427 24 : };
2428 12 : let newer = stamp(&ni) > stamp(existing);
2429 12 : if newer {
2430 12 : *existing = ni;
2431 12 : }
2432 12 : continue;
2433 12 : }
2434 12 : cur.push(ni);
2435 : }
2436 : }
2437 : }
2438 : }
2439 20 : }
2440 :
2441 : // ---------- DELETE /temporal/entities/{id} (5.6.16) ----------
2442 :
2443 2196 : pub async fn delete_temporal(
2444 2196 : State(st): State<AppState>,
2445 2196 : Path(id): Path<String>,
2446 2196 : CleanParams(params): CleanParams,
2447 2196 : headers: HeaderMap,
2448 2196 : ) -> Response {
2449 2196 : let go = async {
2450 2196 : let tenant = tenant_from(&headers)?;
2451 2196 : antares_model::EntityId::new(&id)?;
2452 2184 : check_params(¶ms, &["local"])?;
2453 : // 5.6.16.4: forward to registrations supporting the operation;
2454 : // unsupported proxy modes are Conflict.
2455 2184 : let ctx = st.loader.core();
2456 2184 : gate!(st, &tenant, &headers, "5.6.16", ids: &[&id]).await?;
2457 2184 : let regs = match temporal_write_regs(&st, &tenant, &headers, &ctx, ¶ms, &id).await {
2458 2182 : Ok(regs) => regs,
2459 2 : Err(refused) => return Ok(*refused),
2460 : };
2461 2182 : let deleted = st.temporal.delete(&tenant, &id).await?;
2462 2182 : answer_temporal_attr_write(
2463 2182 : &st,
2464 2182 : &tenant,
2465 2182 : &headers,
2466 2182 : &ctx,
2467 2182 : &id,
2468 2182 : "deleteTemporal",
2469 2182 : reqwest::Method::DELETE,
2470 2182 : "",
2471 2182 : None,
2472 2182 : regs,
2473 2182 : LocalWrite {
2474 2182 : res: deleted.then_some(Ok(())),
2475 2182 : found: deleted,
2476 2182 : missing: format!("temporal entity {id}"),
2477 2182 : applied: "deleted locally",
2478 2182 : },
2479 2182 : )
2480 2182 : .await
2481 2196 : };
2482 2196 : go.await.unwrap_or_else(|e| e.into_response())
2483 2196 : }
2484 :
2485 : // ---------- POST /temporal/entities/{id}/attrs/ (5.6.12) ----------
2486 :
2487 16 : pub async fn add_temporal_attrs(
2488 16 : State(st): State<AppState>,
2489 16 : Path(id): Path<String>,
2490 16 : CleanParams(params): CleanParams,
2491 16 : headers: HeaderMap,
2492 16 : body: Bytes,
2493 16 : ) -> Response {
2494 16 : let go = async {
2495 16 : let tenant = tenant_from(&headers)?;
2496 16 : antares_model::EntityId::new(&id)?;
2497 16 : check_params(¶ms, &["local"])?;
2498 16 : let parsed = parse_body(&st.loader, &headers, &body, BodyKind::Standard).await?;
2499 12 : let obj = parsed.object(NgsiError::BadRequestData(
2500 12 : "fragment must be a JSON object".into(),
2501 12 : ))?;
2502 12 : gate!(st, &tenant, &headers, "5.6.12", ids: &[&id]).await?;
2503 : // 5.6.12 input is pushed history — the 4.5.7 deleted-instance
2504 : // representation is legal (5.5.4 temporal exception), hence
2505 : // allow_null (mirrors 5.6.11).
2506 12 : let mut expanded = expand_entity(
2507 12 : obj,
2508 12 : &parsed.ctx,
2509 12 : ExpandOpts {
2510 12 : fragment: true,
2511 12 : allow_null: true,
2512 12 : temporal: true,
2513 12 : ..Default::default()
2514 12 : },
2515 0 : )?;
2516 : // 5.6.12.4: forwarding — proxy modes without appendAttrsTemporal
2517 : // are Conflict; supporting registrations receive the fragment and
2518 : // the matching attributes are stripped from the local half.
2519 12 : let spec = crate::registry::CsrSpec {
2520 12 : ids: Some(vec![id.clone()]),
2521 12 : ..Default::default()
2522 12 : };
2523 12 : let regs = match crate::federation::write_plan(
2524 12 : &st,
2525 12 : &tenant,
2526 12 : &spec,
2527 12 : &parsed.ctx,
2528 12 : ¶ms,
2529 12 : &headers,
2530 12 : )
2531 12 : .await?
2532 : {
2533 0 : crate::federation::WritePlan::Answered(r) => return Ok(*r),
2534 12 : crate::federation::WritePlan::Forward(regs) => regs,
2535 : };
2536 12 : if !regs.is_empty() {
2537 4 : let mut parts = Vec::new();
2538 4 : let mut fwd = Vec::new();
2539 4 : for reg in ®s {
2540 4 : if !reg.supports("appendAttrsTemporal") {
2541 2 : if reg.is_proxy() {
2542 2 : parts.push(crate::federation::conflict_part("appendAttrsTemporal"));
2543 2 : }
2544 2 : continue;
2545 2 : }
2546 2 : if let Some(frag) = crate::federation::reduce_to_scope(obj, reg, &parsed.ctx) {
2547 2 : fwd.push((reg.clone(), frag));
2548 2 : }
2549 : }
2550 4 : let proxies: Vec<&crate::federation::FedReg> =
2551 4 : regs.iter().filter(|r| r.is_proxy()).collect();
2552 4 : let (rest, has_attrs) = crate::federation::strip_proxied(obj, &proxies, &parsed.ctx);
2553 4 : if has_attrs || proxies.is_empty() {
2554 0 : let mut local = expand_entity(
2555 0 : &rest,
2556 0 : &parsed.ctx,
2557 0 : ExpandOpts {
2558 0 : fragment: true,
2559 0 : allow_null: true,
2560 0 : temporal: true,
2561 0 : ..Default::default()
2562 0 : },
2563 0 : )?;
2564 0 : let ts = now_iso();
2565 0 : stamp_temporal_instances(&mut local, &ts);
2566 0 : let res = st
2567 0 : .temporal
2568 0 : .mutate(&tenant, &id, |doc| {
2569 0 : add_temporal_instances(doc, &local, &ts);
2570 0 : Ok::<(), NgsiError>(())
2571 0 : })
2572 0 : .await?;
2573 0 : parts.push(match res {
2574 0 : Some(Ok(())) => crate::federation::Part {
2575 0 : status: 204,
2576 0 : detail: "added locally".into(),
2577 0 : },
2578 0 : _ => crate::federation::Part {
2579 0 : status: 404,
2580 0 : detail: format!("temporal entity {id} not found locally"),
2581 0 : },
2582 : });
2583 4 : }
2584 4 : let ctx_url = crate::federation::ctx_link_url(&headers, &parsed.ctx.source);
2585 4 : for (reg, frag) in fwd {
2586 2 : parts.push(
2587 2 : crate::federation::forward_part(
2588 2 : &st,
2589 2 : reqwest::Method::POST,
2590 2 : format!(
2591 2 : "{}/ngsi-ld/v1/temporal/entities/{}/attrs",
2592 2 : reg.endpoint,
2593 2 : crate::federation::path_segment(&id)
2594 2 : ),
2595 2 : &[],
2596 2 : &headers,
2597 2 : &tenant,
2598 2 : ®,
2599 2 : &ctx_url,
2600 2 : Some(frag),
2601 2 : )
2602 2 : .await,
2603 : );
2604 : }
2605 4 : return Ok(crate::federation::combine(
2606 4 : parts,
2607 4 : no_content(&tenant),
2608 4 : &tenant,
2609 4 : ));
2610 8 : }
2611 8 : let ts = now_iso();
2612 8 : stamp_temporal_instances(&mut expanded, &ts);
2613 8 : let res = st
2614 8 : .temporal
2615 8 : .mutate(&tenant, &id, |doc| {
2616 0 : add_temporal_instances(doc, &expanded, &ts);
2617 0 : Ok::<(), NgsiError>(())
2618 0 : })
2619 8 : .await?;
2620 0 : match res {
2621 : None => {
2622 8 : Err(NgsiError::ResourceNotFound(format!("temporal entity {id} not found")).into())
2623 : }
2624 0 : Some(Err(e)) => Err(ApiError::from(e)),
2625 0 : Some(Ok(())) => Ok(no_content(&tenant)),
2626 : }
2627 16 : };
2628 16 : go.await.unwrap_or_else(|e| e.into_response())
2629 16 : }
2630 :
2631 : /// 5.6.12.4: append the fragment's Attribute instances to the Temporal
2632 : /// Evolution (instances accumulate — history is never overwritten here).
2633 0 : fn add_temporal_instances(doc: &mut Value, expanded: &Value, ts: &str) {
2634 0 : let Some(target) = doc.as_object_mut() else {
2635 0 : return;
2636 : };
2637 0 : for (k, v) in expanded.as_object().into_iter().flatten() {
2638 0 : if is_meta(k) {
2639 0 : continue;
2640 0 : }
2641 0 : let incoming = v.as_array().cloned().unwrap_or_default();
2642 0 : match target.get_mut(k).and_then(Value::as_array_mut) {
2643 0 : Some(cur) => cur.extend(incoming),
2644 0 : None => {
2645 0 : target.insert(k.clone(), Value::Array(incoming));
2646 0 : }
2647 : }
2648 : }
2649 0 : target.insert("modifiedAt".into(), Value::String(ts.to_owned()));
2650 0 : }
2651 :
2652 : // ---------- DELETE /temporal/entities/{id}/attrs/{attrId} (5.6.13) ----------
2653 :
2654 48 : pub async fn delete_temporal_attr(
2655 48 : State(st): State<AppState>,
2656 48 : Path((id, attr)): Path<(String, String)>,
2657 48 : CleanParams(params): CleanParams,
2658 48 : headers: HeaderMap,
2659 48 : ) -> Response {
2660 48 : let go = async {
2661 48 : let tenant = tenant_from(&headers)?;
2662 48 : antares_model::EntityId::new(&id)?;
2663 : // 5.6.13.4: "If the target Attribute name is not a valid name, then an
2664 : // error of type BadRequestData shall be raised." The shared guard is
2665 : // the one that also refuses dot segments — the name is interpolated
2666 : // into the forwarded request path, where `..` addresses the peer's
2667 : // Temporal Evolution resource instead of its attribute.
2668 48 : antares_model::check_attr_name(&attr)?;
2669 32 : check_params(¶ms, &["datasetId", "deleteAll", "local"])?;
2670 32 : let ctx = request_context(&st.loader, &headers).await?;
2671 32 : gate!(st, &tenant, &headers, "5.6.13", ids: &[&id]).await?;
2672 32 : let attr_iri = antares_jsonld::expand_attr_name(&attr, &ctx)?;
2673 28 : let delete_all = params.get("deleteAll").map(String::as_str) == Some("true");
2674 28 : let want_ds = crate::repr::target_dataset_id(¶ms).map(String::from);
2675 28 : let regs = match temporal_write_regs(&st, &tenant, &headers, &ctx, ¶ms, &id).await {
2676 26 : Ok(regs) => regs,
2677 2 : Err(refused) => return Ok(*refused),
2678 : };
2679 26 : let mut found = false;
2680 26 : let ts = now_iso();
2681 26 : let res = st
2682 26 : .temporal
2683 26 : .mutate(&tenant, &id, |doc| {
2684 6 : let target = antares_store::stored_object(doc)?;
2685 6 : if delete_all
2686 6 : || (want_ds.is_none()
2687 6 : && !target
2688 6 : .get(&attr_iri)
2689 6 : .and_then(Value::as_array)
2690 6 : .is_some_and(|a| a.iter().any(|i| i.get("datasetId").is_some())))
2691 : {
2692 : // deleteAll, or single-instance-set attribute: drop it whole
2693 4 : if target.remove(&attr_iri).is_some() {
2694 2 : found = true;
2695 2 : }
2696 2 : } else if let Some(arr) = target.get_mut(&attr_iri).and_then(Value::as_array_mut) {
2697 : // 5.6.13: only the matching datasetId instance set is deleted
2698 2 : let before = arr.len();
2699 4 : arr.retain(|i| {
2700 4 : i.get("datasetId").and_then(Value::as_str) != want_ds.as_deref()
2701 4 : });
2702 2 : found = arr.len() != before;
2703 2 : if arr.is_empty() {
2704 0 : target.remove(&attr_iri);
2705 2 : }
2706 0 : }
2707 6 : if found {
2708 4 : target.insert("modifiedAt".into(), Value::String(ts.clone()));
2709 4 : }
2710 6 : Ok::<(), NgsiError>(())
2711 6 : })
2712 26 : .await?;
2713 26 : answer_temporal_attr_write(
2714 26 : &st,
2715 26 : &tenant,
2716 26 : &headers,
2717 26 : &ctx,
2718 26 : &id,
2719 26 : "deleteAttrsTemporal",
2720 26 : reqwest::Method::DELETE,
2721 26 : &format!("/attrs/{}", crate::federation::path_segment(&attr)),
2722 26 : None,
2723 26 : regs,
2724 26 : LocalWrite {
2725 26 : res,
2726 26 : found,
2727 26 : missing: format!("attribute {attr}"),
2728 26 : applied: "deleted locally",
2729 26 : },
2730 26 : )
2731 26 : .await
2732 48 : };
2733 48 : go.await.unwrap_or_else(|e| e.into_response())
2734 48 : }
2735 :
2736 : /// 5.6.13.4-5.6.15.4 shared forwarding: proxy registrations without the
2737 : /// operation's support are an error of type Conflict and are never
2738 : /// contacted; supporting registrations receive the forwarded request. None
2739 : /// = no matching registrations (the operation stays purely local).
2740 : /// `path_suffix` arrives with its client-controlled segments already
2741 : /// percent-encoded (RFC 3986 clause 3.3); the entity id is encoded here.
2742 : /// What the local `mutate` did, in the words the answer needs. 5.6.13,
2743 : /// 5.6.14, 5.6.15 and 5.6.16 all answer the same three ways: 204 when the
2744 : /// target was there, ResourceNotFound naming the Entity when the Entity was
2745 : /// not, and ResourceNotFound naming the Attribute or the instance when the
2746 : /// Entity was there but the target inside it was not.
2747 : struct LocalWrite {
2748 : /// `None` when the Temporal Evolution itself is absent.
2749 : res: Option<Result<(), NgsiError>>,
2750 : /// whether the operation's target inside it was there.
2751 : found: bool,
2752 : /// what a 404 names once the Entity itself was found.
2753 : missing: String,
2754 : /// what the 204 Part reports was done here.
2755 : applied: &'static str,
2756 : }
2757 :
2758 : /// 5.6.13 / 5.6.14 / 5.6.15 / 5.6.16 answer: the local result becomes one
2759 : /// 4.3.6 Part, every registration supporting `op` (Table 4.20-1) contributes
2760 : /// another, and 4.3.6.4 combines them into the one status the client sees.
2761 : /// With no registration to forward to — the common case — the local result
2762 : /// is the whole answer. The four operations differ in what they change and
2763 : /// in what they forward, never in how the two halves are answered together.
2764 : /// The registrations a 5.6.13/5.6.14/5.6.15/5.6.16 write forwards to, with
2765 : /// 6.3.17/6.3.18 loop handling already applied. The check belongs here,
2766 : /// ahead of the local write: 508 Loop Detected is an error status, so the
2767 : /// request it answers has to leave the Temporal Evolution as it found it.
2768 : /// The refusal travels boxed — a whole `Response` in an `Err` makes every
2769 : /// `Ok` of this function carry its width.
2770 2256 : async fn temporal_write_regs(
2771 2256 : st: &AppState,
2772 2256 : tenant: &antares_model::TenantId,
2773 2256 : headers: &HeaderMap,
2774 2256 : ctx: &antares_jsonld::Context,
2775 2256 : params: &HashMap<String, String>,
2776 2256 : id: &str,
2777 2256 : ) -> Result<Vec<crate::federation::FedReg>, Box<Response>> {
2778 2256 : let spec = crate::registry::CsrSpec {
2779 2256 : ids: Some(vec![id.to_owned()]),
2780 2256 : ..Default::default()
2781 2256 : };
2782 2256 : match crate::federation::write_plan(st, tenant, &spec, ctx, params, headers)
2783 2256 : .await
2784 2256 : .map_err(|e| Box::new(crate::negotiate::ApiError::from(e).into_response()))?
2785 : {
2786 8 : crate::federation::WritePlan::Answered(refused) => Err(refused),
2787 2248 : crate::federation::WritePlan::Forward(regs) => Ok(regs),
2788 : }
2789 2256 : }
2790 :
2791 : #[allow(clippy::too_many_arguments)] // mirrors the wire: one param per forwarded request part
2792 2248 : async fn answer_temporal_attr_write(
2793 2248 : st: &AppState,
2794 2248 : tenant: &antares_model::TenantId,
2795 2248 : headers: &HeaderMap,
2796 2248 : ctx: &antares_jsonld::Context,
2797 2248 : id: &str,
2798 2248 : op: &str,
2799 2248 : method: reqwest::Method,
2800 2248 : path_suffix: &str,
2801 2248 : body: Option<Value>,
2802 2248 : regs: Vec<crate::federation::FedReg>,
2803 2248 : local: LocalWrite,
2804 2248 : ) -> ApiResult<Response> {
2805 2248 : if regs.is_empty() {
2806 168 : return match local.res {
2807 : None => {
2808 2060 : Err(NgsiError::ResourceNotFound(format!("temporal entity {id} not found")).into())
2809 : }
2810 0 : Some(Err(e)) => Err(ApiError::from(e)),
2811 162 : Some(Ok(())) if local.found => Ok(no_content(tenant)),
2812 : Some(Ok(())) => {
2813 6 : Err(NgsiError::ResourceNotFound(format!("{} not found", local.missing)).into())
2814 : }
2815 : };
2816 20 : }
2817 20 : let local_part = match &local.res {
2818 20 : None => crate::federation::Part {
2819 20 : status: 404,
2820 20 : detail: format!("temporal entity {id} not found locally"),
2821 20 : },
2822 0 : Some(_) if local.found => crate::federation::Part {
2823 0 : status: 204,
2824 0 : detail: local.applied.into(),
2825 0 : },
2826 0 : Some(_) => crate::federation::Part {
2827 0 : status: 404,
2828 0 : detail: format!("{} not found locally", local.missing),
2829 0 : },
2830 : };
2831 20 : let mut parts = vec![local_part];
2832 20 : let ctx_url = crate::federation::ctx_link_url(headers, &ctx.source);
2833 20 : for reg in ®s {
2834 20 : if !reg.supports(op) {
2835 4 : if reg.is_proxy() {
2836 4 : parts.push(crate::federation::conflict_part(op));
2837 4 : }
2838 4 : continue;
2839 16 : }
2840 16 : parts.push(
2841 16 : crate::federation::forward_part(
2842 16 : st,
2843 16 : method.clone(),
2844 16 : format!(
2845 16 : "{}/ngsi-ld/v1/temporal/entities/{}{path_suffix}",
2846 16 : reg.endpoint,
2847 16 : crate::federation::path_segment(id)
2848 16 : ),
2849 16 : &[],
2850 16 : headers,
2851 16 : tenant,
2852 16 : reg,
2853 16 : &ctx_url,
2854 16 : body.clone(),
2855 16 : )
2856 16 : .await,
2857 : );
2858 : }
2859 20 : Ok(crate::federation::combine(
2860 20 : parts,
2861 20 : no_content(tenant),
2862 20 : tenant,
2863 20 : ))
2864 2248 : }
2865 :
2866 : // ---------- PATCH/DELETE .../attrs/{attrId}/{instanceId} (5.6.14/5.6.15) ----------
2867 :
2868 20 : pub async fn modify_temporal_instance(
2869 20 : State(st): State<AppState>,
2870 20 : Path((id, attr, instance_id)): Path<(String, String, String)>,
2871 20 : CleanParams(params): CleanParams,
2872 20 : headers: HeaderMap,
2873 20 : body: Bytes,
2874 20 : ) -> Response {
2875 20 : let go = async {
2876 20 : let tenant = tenant_from(&headers)?;
2877 : // Empty attr segment ⇒ the URI names no resource with a PATCH method
2878 : // (suite 016_02_06 asserts 405 here, vs 400 on the DELETE sibling).
2879 20 : if attr.is_empty() {
2880 0 : return Err(ApiError::Bare(StatusCode::METHOD_NOT_ALLOWED));
2881 20 : }
2882 20 : antares_model::EntityId::new(&id)?;
2883 20 : antares_model::check_attr_name(&attr)?;
2884 20 : antares_model::EntityId::new(&instance_id)
2885 20 : .map_err(|_| NgsiError::BadRequestData("invalid instance id".into()))?;
2886 20 : check_params(¶ms, &["local"])?;
2887 20 : gate!(st, &tenant, &headers, "5.6.14", ids: &[&id]).await?;
2888 20 : let parsed = parse_body(&st.loader, &headers, &body, BodyKind::MergePatch).await?;
2889 20 : let obj = parsed.object(NgsiError::BadRequestData(
2890 20 : "fragment must be a JSON object".into(),
2891 20 : ))?;
2892 20 : let mut wrapper = Map::new();
2893 20 : let mut frag = obj.clone();
2894 20 : frag.remove("@context");
2895 20 : wrapper.insert(attr.clone(), Value::Object(frag));
2896 20 : let expanded = expand_entity(
2897 20 : &wrapper,
2898 20 : &parsed.ctx,
2899 20 : ExpandOpts {
2900 20 : fragment: true,
2901 20 : allow_null: false,
2902 20 : temporal: true,
2903 20 : ..Default::default()
2904 20 : },
2905 0 : )?;
2906 20 : let attr_iri = antares_jsonld::expand_attr_name(&attr, &parsed.ctx)?;
2907 20 : let frag_inst = expanded
2908 20 : .get(&attr_iri)
2909 20 : .and_then(Value::as_array)
2910 20 : .and_then(|a| a.first())
2911 20 : .cloned()
2912 20 : .ok_or_else(|| NgsiError::BadRequestData("invalid instance fragment".into()))?;
2913 18 : let regs =
2914 20 : match temporal_write_regs(&st, &tenant, &headers, &parsed.ctx, ¶ms, &id).await {
2915 18 : Ok(regs) => regs,
2916 2 : Err(refused) => return Ok(*refused),
2917 : };
2918 18 : let ts = now_iso();
2919 18 : let mut found = false;
2920 18 : let res = st
2921 18 : .temporal
2922 18 : .mutate(&tenant, &id, |doc| {
2923 6 : let target = antares_store::stored_object(doc)?;
2924 6 : if let Some(arr) = target.get_mut(&attr_iri).and_then(Value::as_array_mut) {
2925 6 : if let Some(inst) = arr.iter_mut().find(|i| {
2926 6 : i.get("instanceId").and_then(Value::as_str) == Some(instance_id.as_str())
2927 6 : }) {
2928 4 : found = true;
2929 : // 5.6.14.4: "Replace the target Attribute instance
2930 : // identified by the instanceId with the Attribute instance
2931 : // in the EntityTemporal Fragment. The createdAt property
2932 : // of the concerned instance shall remain unchanged, but
2933 : // the modifiedAt property shall be set to the timestamp
2934 : // corresponding to this modification." A replace, so a
2935 : // member only the stored instance carries does not survive
2936 : // it; the instance keeps the identity it is addressed by.
2937 4 : let t = antares_store::stored_object(inst)?;
2938 4 : let kept: Vec<(String, Value)> = ["createdAt", "instanceId"]
2939 4 : .iter()
2940 8 : .filter_map(|k| t.get(*k).map(|v| ((*k).to_owned(), v.clone())))
2941 4 : .collect();
2942 4 : t.clear();
2943 4 : t.extend(kept);
2944 12 : for (k, v) in antares_jsonld::expanded_object(&frag_inst)? {
2945 12 : if matches!(k.as_str(), "createdAt" | "instanceId") {
2946 0 : continue;
2947 12 : }
2948 12 : t.insert(k.clone(), v.clone());
2949 : }
2950 4 : t.insert("modifiedAt".into(), Value::String(ts.clone()));
2951 2 : }
2952 0 : }
2953 6 : Ok::<(), NgsiError>(())
2954 6 : })
2955 18 : .await?;
2956 18 : answer_temporal_attr_write(
2957 18 : &st,
2958 18 : &tenant,
2959 18 : &headers,
2960 18 : &parsed.ctx,
2961 18 : &id,
2962 18 : "updateAttrInstanceTemporal",
2963 18 : reqwest::Method::PATCH,
2964 18 : &format!(
2965 18 : "/attrs/{}/{}",
2966 18 : crate::federation::path_segment(&attr),
2967 18 : crate::federation::path_segment(&instance_id)
2968 18 : ),
2969 18 : Some(parsed.value.clone()),
2970 18 : regs,
2971 18 : LocalWrite {
2972 18 : res,
2973 18 : found,
2974 18 : missing: format!("instance {instance_id}"),
2975 18 : applied: "applied locally",
2976 18 : },
2977 18 : )
2978 18 : .await
2979 20 : };
2980 20 : go.await.unwrap_or_else(|e| e.into_response())
2981 20 : }
2982 :
2983 40 : pub async fn delete_temporal_instance(
2984 40 : State(st): State<AppState>,
2985 40 : Path((id, attr, instance_id)): Path<(String, String, String)>,
2986 40 : CleanParams(params): CleanParams,
2987 40 : headers: HeaderMap,
2988 40 : ) -> Response {
2989 40 : let go = async {
2990 40 : let tenant = tenant_from(&headers)?;
2991 40 : antares_model::EntityId::new(&id)?;
2992 40 : antares_model::check_attr_name(&attr)?;
2993 24 : antares_model::EntityId::new(&instance_id)
2994 24 : .map_err(|_| NgsiError::BadRequestData("invalid instance id".into()))?;
2995 24 : check_params(¶ms, &["local"])?;
2996 24 : let ctx = request_context(&st.loader, &headers).await?;
2997 24 : gate!(st, &tenant, &headers, "5.6.15", ids: &[&id]).await?;
2998 24 : let attr_iri = antares_jsonld::expand_attr_name(&attr, &ctx)?;
2999 24 : let regs = match temporal_write_regs(&st, &tenant, &headers, &ctx, ¶ms, &id).await {
3000 22 : Ok(regs) => regs,
3001 2 : Err(refused) => return Ok(*refused),
3002 : };
3003 22 : let mut found = false;
3004 22 : let ts = now_iso();
3005 22 : let res = st
3006 22 : .temporal
3007 22 : .mutate(&tenant, &id, |doc| {
3008 6 : let target = antares_store::stored_object(doc)?;
3009 6 : if let Some(arr) = target.get_mut(&attr_iri).and_then(Value::as_array_mut) {
3010 6 : let before = arr.len();
3011 6 : arr.retain(|i| {
3012 6 : i.get("instanceId").and_then(Value::as_str) != Some(instance_id.as_str())
3013 6 : });
3014 6 : found = arr.len() != before;
3015 6 : if arr.is_empty() {
3016 4 : target.remove(&attr_iri);
3017 4 : }
3018 0 : }
3019 6 : if found {
3020 4 : target.insert("modifiedAt".into(), Value::String(ts.clone()));
3021 4 : }
3022 6 : Ok::<(), NgsiError>(())
3023 6 : })
3024 22 : .await?;
3025 22 : answer_temporal_attr_write(
3026 22 : &st,
3027 22 : &tenant,
3028 22 : &headers,
3029 22 : &ctx,
3030 22 : &id,
3031 22 : "deleteAttrInstanceTemporal",
3032 22 : reqwest::Method::DELETE,
3033 22 : &format!(
3034 22 : "/attrs/{}/{}",
3035 22 : crate::federation::path_segment(&attr),
3036 22 : crate::federation::path_segment(&instance_id)
3037 22 : ),
3038 22 : None,
3039 22 : regs,
3040 22 : LocalWrite {
3041 22 : res,
3042 22 : found,
3043 22 : missing: format!("instance {instance_id}"),
3044 22 : applied: "applied locally",
3045 22 : },
3046 22 : )
3047 22 : .await
3048 40 : };
3049 40 : go.await.unwrap_or_else(|e| e.into_response())
3050 40 : }
3051 :
3052 : // ---------- POST /temporal/entityOperations/query (6.24) ----------
3053 :
3054 98 : pub async fn batch_temporal_query(
3055 98 : State(st): State<AppState>,
3056 98 : CleanParams(params): CleanParams,
3057 98 : headers: HeaderMap,
3058 98 : body: Bytes,
3059 98 : ) -> Response {
3060 98 : let go = async {
3061 : // 6.3.14 and 6.3.4: a Tenant outside the grammar and an Accept the
3062 : // operation cannot serve are both refused here. Neither VALUE is
3063 : // needed — the inner query reads the headers again — but the request
3064 : // must not reach it having skipped either check.
3065 98 : let tenant = tenant_from(&headers)?;
3066 98 : check_params(
3067 98 : ¶ms,
3068 98 : &["limit", "offset", "count", "options", "format", "local"],
3069 0 : )?;
3070 98 : parse_accept(&headers)?;
3071 98 : let filter = gate!(st, &tenant, &headers, "5.7.4").await?;
3072 :
3073 98 : let parsed = parse_body(&st.loader, &headers, &body, BodyKind::Standard).await?;
3074 98 : let q = parsed.object(NgsiError::BadRequestData(
3075 98 : "query body must be an object".into(),
3076 98 : ))?;
3077 98 : if q.get("type").and_then(Value::as_str) != Some("Query") {
3078 0 : return Err(NgsiError::BadRequestData("body type must be Query".into()).into());
3079 98 : }
3080 : // 5.2.23 Query (temporal reading): members flattened with their
3081 : // Table 5.2.23-1 value spaces enforced, incl. temporalQ (5.2.21)
3082 : // and aggrParams (5.2.44).
3083 98 : let mut vp: HashMap<String, String> = params.clone();
3084 98 : crate::paging::query_doc_params(q, true, &mut vp)?;
3085 58 : query_temporal_inner(&st, &vp, &headers, &filter).await
3086 98 : };
3087 98 : go.await.unwrap_or_else(|e| e.into_response())
3088 98 : }
3089 :
3090 : /// 5.14.5.4: temporal query required; the S1–S4 candidate selection is the
3091 : /// temporal query pipeline itself (5.7.4.4) run unpaged — the ids of its
3092 : /// result set form the EntityMap; the createEntityMapQueryTemporal
3093 : /// registrations are then merged like 5.14.4.
3094 : /// Known ceiling: candidate ids are read from the internal 5.7.4 response capped
3095 : /// at max_limit; raise the cap if temporal sets outgrow it.
3096 38 : pub(crate) async fn build_temporal_map(
3097 38 : st: &AppState,
3098 38 : tenant: &TenantId,
3099 38 : headers: &HeaderMap,
3100 38 : ctx: &antares_jsonld::Context,
3101 38 : params: &HashMap<String, String>,
3102 38 : filter: &crate::policy::Filter,
3103 38 : ) -> ApiResult<Value> {
3104 38 : if !params.contains_key("timerel") {
3105 12 : return Err(NgsiError::BadRequestData(
3106 12 : "a temporal query is required to create a temporal EntityMap (5.14.5.4)".into(),
3107 12 : )
3108 12 : .into());
3109 26 : }
3110 26 : let local_scope = params.get("local").map(String::as_str) == Some("true");
3111 26 : let split = params.get("splitEntities").map(String::as_str) == Some("true");
3112 26 : let mut eff: HashMap<String, String> = if split && !local_scope {
3113 0 : params
3114 0 : .iter()
3115 0 : .filter(|(k, _)| {
3116 0 : [
3117 0 : "id",
3118 0 : "idPattern",
3119 0 : "type",
3120 0 : "local",
3121 0 : "timerel",
3122 0 : "timeAt",
3123 0 : "endTimeAt",
3124 0 : "timeproperty",
3125 0 : ]
3126 0 : .contains(&k.as_str())
3127 0 : })
3128 0 : .map(|(k, v)| (k.clone(), v.clone()))
3129 0 : .collect()
3130 : } else {
3131 26 : params.clone()
3132 : };
3133 130 : for k in [
3134 26 : "entityMap",
3135 26 : "entityMapLifetime",
3136 26 : "splitEntities",
3137 26 : "offset",
3138 26 : "count",
3139 130 : ] {
3140 130 : eff.remove(k);
3141 130 : }
3142 26 : eff.insert("limit".into(), st.max_limit.to_string());
3143 : // Box::pin: build_temporal_map is reachable from query_temporal_inner
3144 : // (entityMap=true), so this recursive edge needs indirection.
3145 26 : let resp = Box::pin(query_temporal_inner(st, &eff, headers, filter)).await?;
3146 26 : let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
3147 26 : .await
3148 26 : .map_err(|e| NgsiError::InternalError(format!("temporal candidate read: {e}")))?;
3149 : // 5.5.14: the map FIXES the Entities considered by every later request, so
3150 : // an unreadable candidate set must fail rather than become "no candidates".
3151 26 : let candidates: Value = serde_json::from_slice(&bytes)
3152 26 : .map_err(|_| NgsiError::InternalError("temporal candidate parse".into()))?;
3153 26 : let mut emap = Map::new();
3154 26 : if let Some(arr) = candidates.as_array() {
3155 26 : for d in arr {
3156 24 : if let Some(id) = d.get("id").and_then(Value::as_str) {
3157 24 : emap.insert(id.to_owned(), json!(["@none"]));
3158 24 : }
3159 : }
3160 0 : }
3161 26 : crate::entity_map::merge_and_store_map(st, tenant, headers, ctx, params, true, emap).await
3162 38 : }
3163 :
3164 : #[cfg(test)]
3165 : mod clause_4_11 {
3166 : use super::*;
3167 : use serde_json::json;
3168 :
3169 : /// 5.7.3.4 / 4.5.5.3: instances with the same datasetId (or both default)
3170 : /// AND the same timeproperty value are CONFLICTING instances of one slot
3171 : /// — the merge resolves them to one (most recent modifiedAt wins), never
3172 : /// serves both. Regression: the same instance held by two federated
3173 : /// brokers came back twice (IOP_EXT_TMP_02_05).
3174 : #[tokio::test]
3175 4 : async fn merge_resolves_same_slot_instances_to_one() {
3176 4 : let mut base = json!({"id": "urn:e", "type": "T", "speed": [
3177 4 : {"type": "Property", "value": 10, "observedAt": "2026-05-01T00:00:00Z",
3178 4 : "modifiedAt": "2026-05-01T00:00:00Z"},
3179 : ]});
3180 4 : let add = json!({"id": "urn:e", "type": "T", "speed": [
3181 4 : {"type": "Property", "value": 11, "observedAt": "2026-05-01T00:00:00Z",
3182 4 : "modifiedAt": "2026-06-01T00:00:00Z"},
3183 4 : {"type": "Property", "value": 20, "observedAt": "2026-05-02T00:00:00Z",
3184 4 : "modifiedAt": "2026-05-02T00:00:00Z"},
3185 4 : {"type": "Property", "value": 30, "observedAt": "2026-05-01T00:00:00Z",
3186 4 : "datasetId": "urn:d:1", "modifiedAt": "2026-05-01T00:00:00Z"},
3187 : ]});
3188 4 : merge_temporal_docs(&mut base, &add, false, "observedAt");
3189 4 : let speed = base["speed"].as_array().expect("array");
3190 : // default-instance duplicate collapsed (newer modifiedAt won),
3191 : // the other timestamp appended, the datasetId instance is its own slot
3192 4 : assert_eq!(speed.len(), 3, "{speed:?}");
3193 4 : let default_slot: Vec<&Value> = speed
3194 4 : .iter()
3195 12 : .filter(|i| i.get("datasetId").is_none() && i["observedAt"] == "2026-05-01T00:00:00Z")
3196 4 : .collect();
3197 4 : assert_eq!(default_slot.len(), 1);
3198 4 : assert_eq!(default_slot[0]["value"], 11, "newer modifiedAt wins");
3199 4 : }
3200 :
3201 : /// 4.3.6.2: "An auxiliary Context Source Registration never overrides
3202 : /// data held directly within a Context Broker. […] Context data from
3203 : /// auxiliary context sources is only included if it is supplementary to
3204 : /// the context data otherwise available to the Context Broker." On a
3205 : /// Temporal Evolution the unit is the instance, so an auxiliary instance
3206 : /// enters only where no other source supplied that timeproperty value.
3207 : #[tokio::test]
3208 4 : async fn an_auxiliary_instance_supplements_but_never_overrides() {
3209 4 : let mut base = json!({"id": "urn:e", "type": "T", "speed": [
3210 4 : {"type": "Property", "value": 10, "observedAt": "2026-05-01T00:00:00Z",
3211 4 : "modifiedAt": "2026-05-01T00:00:00Z"},
3212 : ]});
3213 4 : let add = json!({"id": "urn:e", "type": "T", "speed": [
3214 : // same slot as the local instance, and newer — still refused
3215 4 : {"type": "Property", "value": 99, "observedAt": "2026-05-01T00:00:00Z",
3216 4 : "modifiedAt": "2026-07-01T00:00:00Z"},
3217 : // a timestamp nobody else supplied: supplementary, so included
3218 4 : {"type": "Property", "value": 20, "observedAt": "2026-05-02T00:00:00Z"},
3219 : ]});
3220 4 : merge_temporal_docs(&mut base, &add, true, "observedAt");
3221 4 : let speed = base["speed"].as_array().expect("array");
3222 4 : assert_eq!(speed.len(), 2, "{speed:?}");
3223 4 : assert!(
3224 4 : !Value::Array(speed.clone()).to_string().contains("99"),
3225 : "an auxiliary instance may not override an occupied slot: {speed:?}"
3226 : );
3227 4 : assert_eq!(speed[0]["value"], 10);
3228 4 : assert_eq!(speed[1]["value"], 20);
3229 4 : }
3230 :
3231 : /// 4.6.3 leaves the seconds fraction optional, so two Context Sources
3232 : /// holding one instance may spell its timeproperty differently. The slot
3233 : /// of 4.5.5.3 is the INSTANT, not the spelling: a byte comparison treats
3234 : /// the two as separate slots and serves the same instance twice — the
3235 : /// IOP_EXT_TMP_02_05 duplicate, reached through a different door.
3236 : #[tokio::test]
3237 4 : async fn one_instant_spelled_two_ways_is_still_one_slot() {
3238 4 : let mut base = json!({"id": "urn:e", "type": "T", "speed": [
3239 4 : {"type": "Property", "value": 10, "observedAt": "2026-05-01T00:00:00Z",
3240 4 : "modifiedAt": "2026-05-01T00:00:00Z"},
3241 : ]});
3242 4 : let add = json!({"id": "urn:e", "type": "T", "speed": [
3243 4 : {"type": "Property", "value": 11, "observedAt": "2026-05-01T00:00:00.000Z",
3244 4 : "modifiedAt": "2026-06-01T00:00:00Z"},
3245 : ]});
3246 4 : merge_temporal_docs(&mut base, &add, false, "observedAt");
3247 4 : let speed = base["speed"].as_array().expect("array");
3248 4 : assert_eq!(speed.len(), 1, "one instant is one slot: {speed:?}");
3249 4 : assert_eq!(speed[0]["value"], 11, "newer modifiedAt wins");
3250 4 : }
3251 :
3252 : /// The winner of a conflicting slot is "the most recent modifiedAt", and
3253 : /// which of two `modifiedAt` values is more recent is a comparison of
3254 : /// instants for the same reason. A remote instance stamped
3255 : /// `…:00.500Z` is LATER than a local one stamped `…:00Z`, though its
3256 : /// bytes sort earlier.
3257 : #[tokio::test]
3258 4 : async fn the_more_recent_modified_at_wins_across_fraction_spellings() {
3259 4 : let mut base = json!({"id": "urn:e", "type": "T", "speed": [
3260 4 : {"type": "Property", "value": 10, "observedAt": "2026-05-01T00:00:00Z",
3261 4 : "modifiedAt": "2026-05-01T09:00:00Z"},
3262 : ]});
3263 4 : let add = json!({"id": "urn:e", "type": "T", "speed": [
3264 4 : {"type": "Property", "value": 11, "observedAt": "2026-05-01T00:00:00Z",
3265 4 : "modifiedAt": "2026-05-01T09:00:00.500Z"},
3266 : ]});
3267 4 : merge_temporal_docs(&mut base, &add, false, "observedAt");
3268 4 : let speed = base["speed"].as_array().expect("array");
3269 4 : assert_eq!(speed.len(), 1, "{speed:?}");
3270 4 : assert_eq!(speed[0]["value"], 11, "the later instant wins: {speed:?}");
3271 4 : }
3272 :
3273 : /// 4.3.6.2 auxiliary supplementation is decided on the same slot, so an
3274 : /// auxiliary instance that respells an occupied instant is still refused.
3275 : #[tokio::test]
3276 4 : async fn an_auxiliary_respelling_of_an_occupied_slot_is_refused() {
3277 4 : let mut base = json!({"id": "urn:e", "type": "T", "speed": [
3278 4 : {"type": "Property", "value": 10, "observedAt": "2026-05-01T00:00:00Z"},
3279 : ]});
3280 4 : let add = json!({"id": "urn:e", "type": "T", "speed": [
3281 4 : {"type": "Property", "value": 99, "observedAt": "2026-05-01T00:00:00.000Z"},
3282 : ]});
3283 4 : merge_temporal_docs(&mut base, &add, true, "observedAt");
3284 4 : let speed = base["speed"].as_array().expect("array");
3285 4 : assert_eq!(speed.len(), 1, "{speed:?}");
3286 4 : assert_eq!(speed[0]["value"], 10);
3287 4 : }
3288 : }
3289 :
3290 : #[cfg(test)]
3291 : mod clause_6_3_10 {
3292 : use super::*;
3293 : use serde_json::json;
3294 :
3295 : /// One attribute with `n` instances, one per minute from 00:00.
3296 24 : fn evolution(n: usize) -> Value {
3297 24 : let speed: Vec<Value> = (0..n)
3298 444 : .map(|i| json!({"type": "Property", "value": i, "observedAt": at(i)}))
3299 24 : .collect();
3300 24 : json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle", "speed": speed})
3301 24 : }
3302 :
3303 708 : fn at(i: usize) -> String {
3304 708 : format!("2020-01-01T00:{i:02}:00Z")
3305 708 : }
3306 :
3307 4 : fn tq(timerel: &str, time_at: &str) -> TemporalQ {
3308 4 : let mut p = HashMap::new();
3309 4 : p.insert("timerel".to_owned(), timerel.to_owned());
3310 4 : p.insert("timeAt".to_owned(), time_at.to_owned());
3311 4 : TemporalQ::from_params(&p, true).unwrap().unwrap()
3312 4 : }
3313 :
3314 28 : async fn windowed(doc: &Value, tq: Option<&TemporalQ>, last_n: Option<usize>) -> Windowed {
3315 28 : let mut w = window(doc, tq, last_n, None, None, None, "observedAt");
3316 28 : truncate(&mut w, "observedAt", last_n.is_some());
3317 28 : w
3318 28 : }
3319 :
3320 12 : fn observed(w: &Windowed) -> Vec<&str> {
3321 12 : w.attrs["speed"]
3322 12 : .iter()
3323 80 : .map(|i| i["observedAt"].as_str().unwrap())
3324 12 : .collect()
3325 12 : }
3326 :
3327 : /// 6.3.10: a temporal retrieval the broker cannot serve in full is
3328 : /// answered with 206 and a Content-Range. The body must then BE the
3329 : /// partial representation the header describes — a window wide enough to
3330 : /// select more instances than the broker serves at once is cut to the
3331 : /// ceiling, oldest first, and the advertised range ends at the last
3332 : /// instance returned.
3333 : #[tokio::test]
3334 4 : async fn wide_window_is_cut_to_the_ceiling_and_the_range_matches_the_body() {
3335 4 : let doc = evolution(20);
3336 4 : let q = tq("after", "2019-01-01T00:00:00Z");
3337 4 : let w = windowed(&doc, Some(&q), None).await;
3338 4 : assert_eq!(w.attrs["speed"].len(), TEMPORAL_INSTANCE_LIMIT);
3339 4 : assert!(w.truncated);
3340 4 : let got = observed(&w);
3341 4 : assert_eq!(got[0], at(0));
3342 4 : assert_eq!(
3343 4 : got[TEMPORAL_INSTANCE_LIMIT - 1],
3344 4 : at(TEMPORAL_INSTANCE_LIMIT - 1)
3345 : );
3346 4 : assert!(
3347 4 : !got.contains(&at(19).as_str()),
3348 : "instances beyond the ceiling must not be served: {got:?}"
3349 : );
3350 : // 5.7.3.4 + 6.3.10: start is the requested lower bound, end the last
3351 : // instance in the body, so the header cannot promise more than it sent
3352 4 : assert_eq!(
3353 4 : content_range(
3354 4 : w.truncated,
3355 4 : w.ts_min.as_deref(),
3356 4 : w.ts_max.as_deref(),
3357 4 : Some(&q),
3358 4 : None
3359 4 : ),
3360 4 : Some(format!(
3361 4 : "date-time 2019-01-01T00:00:00Z-{}/*",
3362 4 : at(TEMPORAL_INSTANCE_LIMIT - 1)
3363 4 : ))
3364 4 : );
3365 4 : }
3366 :
3367 : /// Two attributes, `speed` minutes 0..n and `heading` minutes 5..m.
3368 8 : fn evolution2(n: usize, m: usize) -> Value {
3369 8 : let mut doc = evolution(n);
3370 8 : doc["heading"] = (5..m)
3371 208 : .map(|i| json!({"type": "Property", "value": i, "observedAt": at(i)}))
3372 8 : .collect();
3373 8 : doc
3374 8 : }
3375 :
3376 8 : fn last_observed(w: &Windowed, attr: &str) -> Option<String> {
3377 8 : w.attrs[attr]
3378 8 : .iter()
3379 52 : .filter_map(|i| i["observedAt"].as_str().map(str::to_owned))
3380 8 : .max()
3381 8 : }
3382 :
3383 : /// 6.3.10: the partial content IS the representation the Content-Range
3384 : /// describes — so the cut is one time boundary for the whole entity.
3385 : /// With `speed` over-full first, `heading` is trimmed to the same last
3386 : /// instant, and nothing of either attribute lies past the advertised
3387 : /// range-end (a client continuing from it misses no instance).
3388 : #[tokio::test]
3389 4 : async fn the_cut_is_one_time_boundary_across_attributes() {
3390 4 : let w = windowed(&evolution2(21, 31), None, None).await;
3391 4 : assert!(w.truncated);
3392 4 : let end = at(TEMPORAL_INSTANCE_LIMIT - 1);
3393 4 : assert_eq!(w.attrs["speed"].len(), TEMPORAL_INSTANCE_LIMIT);
3394 4 : assert_eq!(last_observed(&w, "speed").as_deref(), Some(end.as_str()));
3395 4 : assert_eq!(
3396 4 : last_observed(&w, "heading").as_deref(),
3397 4 : Some(end.as_str()),
3398 : "heading must stop at speed's boundary, not at its own ninth instance"
3399 : );
3400 4 : assert_eq!(w.attrs["heading"].len(), TEMPORAL_INSTANCE_LIMIT - 5);
3401 4 : assert_eq!(w.ts_max.as_deref(), Some(end.as_str()));
3402 52 : for inst in w.attrs["heading"].iter().chain(w.attrs["speed"].iter()) {
3403 52 : assert!(
3404 52 : inst["observedAt"].as_str().expect("t") <= end.as_str(),
3405 : "no instance may lie past the advertised range-end: {inst}"
3406 : );
3407 : }
3408 : // backwards (lastN): the boundary is the LATEST ninth instant, so the
3409 : // page covers [heading's ninth-newest, newest] and `speed`, which ends
3410 : // before that, is empty on this page rather than incoherently present
3411 4 : let w = windowed(&evolution2(21, 31), None, Some(20)).await;
3412 4 : assert!(w.truncated);
3413 4 : assert_eq!(w.attrs["heading"].len(), TEMPORAL_INSTANCE_LIMIT);
3414 4 : assert_eq!(w.attrs["heading"][0]["observedAt"], json!(at(30)));
3415 4 : assert_eq!(
3416 4 : w.ts_min.as_deref(),
3417 4 : Some(at(30 - TEMPORAL_INSTANCE_LIMIT + 1).as_str())
3418 : );
3419 4 : assert!(
3420 4 : w.attrs["speed"].is_empty(),
3421 4 : "speed lies entirely before the page boundary: {:?}",
3422 4 : w.attrs["speed"]
3423 4 : );
3424 4 : }
3425 :
3426 : /// 5.7.3.4/5.7.4.4 lastN "shall be limited to the specified number of
3427 : /// instances" — an upper limit, not an entitlement: a lastN above the
3428 : /// broker ceiling is served up to the ceiling (newest first) and the
3429 : /// answer is the partial one. The Content-Range size stays the requested
3430 : /// lastN, its start-end pair the instants actually returned.
3431 : #[tokio::test]
3432 4 : async fn last_n_above_the_ceiling_is_clamped_to_it() {
3433 4 : let doc = evolution(20);
3434 4 : let w = windowed(&doc, None, Some(20)).await;
3435 4 : assert_eq!(w.attrs["speed"].len(), TEMPORAL_INSTANCE_LIMIT);
3436 4 : assert!(w.truncated);
3437 4 : let got = observed(&w);
3438 4 : assert_eq!(got[0], at(19), "lastN delivers newest first");
3439 4 : assert_eq!(
3440 4 : got[TEMPORAL_INSTANCE_LIMIT - 1],
3441 4 : at(20 - TEMPORAL_INSTANCE_LIMIT)
3442 : );
3443 4 : assert_eq!(
3444 4 : content_range(
3445 4 : w.truncated,
3446 4 : w.ts_min.as_deref(),
3447 4 : w.ts_max.as_deref(),
3448 4 : None,
3449 4 : Some(20)
3450 4 : ),
3451 4 : Some(format!(
3452 4 : "date-time {}-{}/20",
3453 4 : at(19),
3454 4 : at(20 - TEMPORAL_INSTANCE_LIMIT)
3455 4 : ))
3456 4 : );
3457 4 : }
3458 :
3459 : /// 5.7.3.4: temporalQ is optional on retrieval, so a request naming no
3460 : /// window at all asks for the whole Temporal Evolution. It is still
3461 : /// bounded by the same ceiling, and still answered as partial.
3462 : #[tokio::test]
3463 4 : async fn a_request_with_no_window_is_capped_by_default() {
3464 4 : let doc = evolution(20);
3465 4 : let w = windowed(&doc, None, None).await;
3466 4 : assert_eq!(w.attrs["speed"].len(), TEMPORAL_INSTANCE_LIMIT);
3467 4 : assert!(w.truncated);
3468 4 : assert_eq!(
3469 4 : w.ts_max.as_deref(),
3470 4 : Some(at(TEMPORAL_INSTANCE_LIMIT - 1).as_str())
3471 : );
3472 4 : assert_eq!(
3473 4 : content_range(
3474 4 : w.truncated,
3475 4 : w.ts_min.as_deref(),
3476 4 : w.ts_max.as_deref(),
3477 4 : None,
3478 4 : None
3479 4 : ),
3480 4 : Some(format!(
3481 4 : "date-time {}-{}/*",
3482 4 : at(0),
3483 4 : at(TEMPORAL_INSTANCE_LIMIT - 1)
3484 4 : ))
3485 4 : );
3486 4 : }
3487 :
3488 : /// 6.3.10: partial content is conditional on truncation — a result the
3489 : /// broker serves in full is a plain 200 with no Content-Range.
3490 : #[tokio::test]
3491 4 : async fn a_complete_result_is_not_partial_content() {
3492 4 : let doc = evolution(TEMPORAL_INSTANCE_LIMIT);
3493 4 : let w = windowed(&doc, None, None).await;
3494 4 : assert_eq!(w.attrs["speed"].len(), TEMPORAL_INSTANCE_LIMIT);
3495 4 : assert!(!w.truncated);
3496 4 : assert_eq!(
3497 4 : content_range(
3498 4 : w.truncated,
3499 4 : w.ts_min.as_deref(),
3500 4 : w.ts_max.as_deref(),
3501 4 : None,
3502 4 : None
3503 4 : ),
3504 4 : None
3505 4 : );
3506 4 : }
3507 :
3508 : /// 4.6.3 allows a DateTime to carry a seconds fraction or leave it out,
3509 : /// and both spellings of one instant are the same instant. The window's
3510 : /// own bounds are compared as raw strings nowhere: `.` sorts before `Z`,
3511 : /// so `…:00.500Z` reads as EARLIER than `…:00Z` on a byte compare, and a
3512 : /// Content-Range built from those bounds would name a range the body
3513 : /// contradicts. The instances are sorted on the canonical key already
3514 : /// (`dt_key`); the bounds are on the same key or they disagree with the
3515 : /// order they summarize.
3516 : #[tokio::test]
3517 4 : async fn the_window_bounds_are_the_true_extremes_across_fraction_spellings() {
3518 4 : let doc = json!({
3519 4 : "id": "urn:ngsi-ld:Vehicle:1",
3520 4 : "type": "Vehicle",
3521 4 : "speed": [
3522 4 : {"type": "Property", "value": 1, "observedAt": "2020-01-01T00:09:00Z"},
3523 4 : {"type": "Property", "value": 2, "observedAt": "2020-01-01T00:09:00.500Z"},
3524 : ],
3525 : });
3526 4 : let w = windowed(&doc, None, None).await;
3527 4 : assert_eq!(w.attrs["speed"].len(), 2);
3528 4 : assert_eq!(
3529 4 : observed(&w),
3530 4 : vec!["2020-01-01T00:09:00Z", "2020-01-01T00:09:00.500Z"],
3531 : "the instances themselves sort on the canonical key"
3532 : );
3533 4 : assert_eq!(w.ts_min.as_deref(), Some("2020-01-01T00:09:00Z"));
3534 4 : assert_eq!(w.ts_max.as_deref(), Some("2020-01-01T00:09:00.500Z"));
3535 4 : }
3536 : }
3537 :
3538 : #[cfg(test)]
3539 : mod forwarded_path_encoding {
3540 : use crate::AppState;
3541 : use axum::body::Body;
3542 : use axum::http::{Request, StatusCode};
3543 : use std::io::{Read, Write};
3544 : use std::sync::{Arc, Mutex};
3545 : use tower::ServiceExt;
3546 :
3547 : /// An entity id is a URI (4.6.2), and `#` is legal in one. It also ends a
3548 : /// path in RFC 3986 clause 3.3, so the id has to be percent-encoded
3549 : /// wherever it becomes a path segment.
3550 : const ENTITY: &str = "urn:ngsi-ld:Vehicle:temporal-enc#frag";
3551 : const ENCODED: &str = "urn:ngsi-ld:Vehicle:temporal-enc%23frag";
3552 :
3553 : /// A Context Source answering 204 to everything, recording request lines.
3554 8 : fn mock_source() -> (u16, Arc<Mutex<Vec<String>>>) {
3555 8 : let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
3556 8 : let port = listener.local_addr().expect("addr").port();
3557 8 : let seen: Arc<Mutex<Vec<String>>> = Arc::default();
3558 8 : let log = seen.clone();
3559 8 : std::thread::spawn(move || {
3560 8 : for stream in listener.incoming() {
3561 8 : let Ok(mut s) = stream else { continue };
3562 8 : let mut buf = [0u8; 8192];
3563 8 : let n = s.read(&mut buf).unwrap_or(0);
3564 8 : if let Some(line) = String::from_utf8_lossy(&buf[..n]).lines().next() {
3565 8 : log.lock().expect("lock").push(line.to_owned());
3566 8 : }
3567 8 : let _ = s.write_all(
3568 8 : b"HTTP/1.1 204 No Content\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
3569 8 : );
3570 : }
3571 8 : });
3572 8 : (port, seen)
3573 8 : }
3574 :
3575 12 : fn state() -> AppState {
3576 : // the mock source is loopback, denied by the egress policy by default
3577 12 : crate::allow_private();
3578 12 : AppState::new("antares-temporal-enc".into())
3579 12 : }
3580 :
3581 52 : async fn send(st: &AppState, req: Request<Body>) -> axum::http::Response<Body> {
3582 52 : crate::router(st.clone())
3583 52 : .oneshot(req)
3584 52 : .await
3585 52 : .expect("response")
3586 52 : }
3587 :
3588 12 : async fn post(st: &AppState, uri: &str, body: String) -> axum::http::Response<Body> {
3589 12 : let req = Request::builder()
3590 12 : .method("POST")
3591 12 : .uri(uri)
3592 12 : .header("Content-Type", "application/json")
3593 12 : .header("Content-Length", body.len())
3594 12 : .body(Body::from(body))
3595 12 : .expect("request");
3596 12 : send(st, req).await
3597 12 : }
3598 :
3599 8 : async fn register(st: &AppState, port: u16, id: &str, entity: &str) {
3600 8 : let doc = serde_json::json!({
3601 8 : "id": format!("urn:ngsi-ld:ContextSourceRegistration:{id}"),
3602 8 : "type": "ContextSourceRegistration",
3603 8 : "mode": "redirect",
3604 8 : "operations": ["deleteAttrsTemporal", "deleteAttrInstanceTemporal"],
3605 8 : "information": [{"entities": [{"type": "Vehicle", "id": entity}]}],
3606 8 : "endpoint": format!("http://127.0.0.1:{port}"),
3607 : });
3608 8 : assert_eq!(
3609 8 : post(st, "/ngsi-ld/v1/csourceRegistrations", doc.to_string())
3610 8 : .await
3611 8 : .status(),
3612 : StatusCode::CREATED,
3613 : "registration create"
3614 : );
3615 8 : }
3616 :
3617 : /// 5.6.13.4/5.6.15.4: the operation is forwarded to the registration
3618 : /// endpoint with the target resource named in the request path. The id,
3619 : /// the Attribute name and the instanceId arrive percent-decoded from this
3620 : /// broker's own path, so splicing them raw would let a `#` end the
3621 : /// forwarded path (RFC 3986 clause 3.3) and turn Delete Attribute into
3622 : /// Delete Temporal Evolution of an Entity (5.6.16) on the peer.
3623 : #[tokio::test(flavor = "multi_thread")]
3624 4 : async fn a_hash_in_the_id_reaches_the_peer_encoded_not_truncated() {
3625 4 : let st = state();
3626 4 : let (port, seen) = mock_source();
3627 4 : register(&st, port, "csr-temporal-enc", ENTITY).await;
3628 :
3629 : // each suffix is already in its encoded spelling, so the forwarded
3630 : // path must repeat it verbatim
3631 8 : for suffix in ["/attrs/speed", "/attrs/speed/urn:ngsi-ld:Instance:1%23x"] {
3632 8 : let req = Request::builder()
3633 8 : .method("DELETE")
3634 8 : .uri(format!("/ngsi-ld/v1/temporal/entities/{ENCODED}{suffix}"))
3635 8 : .body(Body::empty())
3636 8 : .expect("request");
3637 8 : let status = send(&st, req).await.status();
3638 8 : assert_ne!(status, StatusCode::BAD_REQUEST, "{suffix}");
3639 8 : let lines = seen.lock().expect("lock").clone();
3640 8 : let last = lines.last().cloned().unwrap_or_default();
3641 8 : assert!(
3642 8 : last.contains(&format!("/ngsi-ld/v1/temporal/entities/{ENCODED}{suffix}")),
3643 4 : "forwarded request line {last:?} for suffix {suffix}"
3644 4 : );
3645 4 : // the negative assertion: the peer must never see a path that
3646 4 : // stops at the entity resource
3647 8 : assert!(
3648 8 : !last.contains("temporal-enc HTTP/"),
3649 4 : "forwarded path truncated at the `#`: {last:?}"
3650 4 : );
3651 4 : }
3652 4 : }
3653 :
3654 : /// 5.6.13.4: "If the target Attribute name is not a valid name, then an
3655 : /// error of type BadRequestData shall be raised." A name begins with a
3656 : /// letter (4.6.2), so no valid name is a relative-path dot segment (RFC
3657 : /// 3986 clause 5.2.4) — and such a name in a forwarded path would address
3658 : /// the peer's Temporal Evolution resource instead of its Attribute, so it
3659 : /// is refused before anything leaves this broker.
3660 : #[tokio::test(flavor = "multi_thread")]
3661 4 : async fn a_dot_segment_attribute_name_is_refused_and_never_forwarded() {
3662 : const TARGET: &str = "urn:ngsi-ld:Vehicle:temporal-dots";
3663 4 : let st = state();
3664 4 : let (port, seen) = mock_source();
3665 4 : register(&st, port, "csr-temporal-dots", TARGET).await;
3666 : // raw, decoded once by this broker, and decoded once more by the peer
3667 16 : for attr in ["..", "%2e%2e", "%252e%252e", "."] {
3668 32 : for suffix in ["", "/urn:ngsi-ld:Instance:1"] {
3669 32 : let req = Request::builder()
3670 32 : .method("DELETE")
3671 32 : .uri(format!(
3672 4 : "/ngsi-ld/v1/temporal/entities/{TARGET}/attrs/{attr}{suffix}"
3673 4 : ))
3674 32 : .body(Body::empty())
3675 32 : .expect("request");
3676 32 : assert_eq!(
3677 32 : send(&st, req).await.status(),
3678 4 : StatusCode::BAD_REQUEST,
3679 4 : "attribute name {attr:?} with suffix {suffix:?}"
3680 4 : );
3681 4 : }
3682 4 : }
3683 4 : assert!(
3684 4 : seen.lock().expect("lock").is_empty(),
3685 4 : "a rejected attribute name must never reach a registration endpoint"
3686 4 : );
3687 4 : }
3688 :
3689 : /// 5.6.11.4: on creation the response carries a Location header holding
3690 : /// the resource URI of the created Temporal Representation. A URI has its
3691 : /// reserved characters percent-encoded (RFC 3986 clause 3.3), so a `#` in
3692 : /// the id may not be spliced raw — there it would read as the start of a
3693 : /// fragment identifier and address the entity collection.
3694 : #[tokio::test(flavor = "multi_thread")]
3695 4 : async fn the_location_header_percent_encodes_the_id() {
3696 4 : let st = state();
3697 4 : let doc = serde_json::json!({
3698 4 : "id": ENTITY, "type": "Vehicle",
3699 4 : "speed": [{"type": "Property", "value": 1,
3700 4 : "observedAt": "2026-03-01T12:05:00Z"}],
3701 : });
3702 4 : let res = post(&st, "/ngsi-ld/v1/temporal/entities", doc.to_string()).await;
3703 4 : assert_eq!(res.status(), StatusCode::CREATED);
3704 4 : assert_eq!(
3705 4 : res.headers()
3706 4 : .get("Location")
3707 4 : .and_then(|v| v.to_str().ok())
3708 4 : .unwrap_or_default(),
3709 4 : format!("/ngsi-ld/v1/temporal/entities/{ENCODED}")
3710 4 : );
3711 4 : }
3712 : }
3713 :
3714 : #[cfg(test)]
3715 : mod clause_4_5_19 {
3716 : use super::*;
3717 : use serde_json::json;
3718 :
3719 : /// 4.5.19.1: "The duration shall be a string in the format
3720 : /// `P[n]Y[n]M[n]DT[n]H[n]M[n]S` or `P[n]W` … For example,
3721 : /// `"P3Y6M4DT12H30M5S"` represents a duration of "three years, six
3722 : /// months, four days, twelve hours, thirty minutes, and five seconds"."
3723 : /// A period mixing date and time elements is therefore valid, and
3724 : /// "PT0S" spans the whole time range of the query.
3725 : #[test]
3726 4 : fn a_mixed_date_and_time_duration_is_a_valid_period() {
3727 : const DAY: i64 = 86_400;
3728 4 : assert_eq!(
3729 4 : parse_iso_duration("P3Y6M4DT12H30M5S"),
3730 4 : Some(AggrPeriod::Months(42, 4 * DAY + 12 * 3600 + 30 * 60 + 5))
3731 : );
3732 4 : assert_eq!(
3733 4 : parse_iso_duration("P1Y1D"),
3734 : Some(AggrPeriod::Months(12, DAY))
3735 : );
3736 4 : assert_eq!(
3737 4 : parse_iso_duration("P1MT1H"),
3738 : Some(AggrPeriod::Months(1, 3600))
3739 : );
3740 : // the pure forms are unchanged
3741 4 : assert_eq!(parse_iso_duration("PT0S"), Some(AggrPeriod::Whole));
3742 4 : assert_eq!(parse_iso_duration("P0D"), Some(AggrPeriod::Whole));
3743 4 : assert_eq!(parse_iso_duration("P1M"), Some(AggrPeriod::Months(1, 0)));
3744 4 : assert_eq!(parse_iso_duration("PT90M"), Some(AggrPeriod::Seconds(5400)));
3745 4 : assert_eq!(
3746 4 : parse_iso_duration("P1W"),
3747 4 : Some(AggrPeriod::Seconds(7 * DAY))
3748 : );
3749 : // and the grammar still rejects what is not a duration
3750 4 : assert_eq!(parse_iso_duration("P1X"), None);
3751 4 : assert_eq!(parse_iso_duration("1Y"), None);
3752 4 : assert_eq!(parse_iso_duration("P1"), None);
3753 4 : }
3754 :
3755 12 : fn windowed(times: &[&str]) -> Windowed {
3756 12 : let instances: Vec<Value> = times
3757 12 : .iter()
3758 28 : .map(|t| json!({"type": "Property", "value": 1, "observedAt": t}))
3759 12 : .collect();
3760 12 : let mut attrs = std::collections::BTreeMap::new();
3761 12 : attrs.insert("speed".to_owned(), instances);
3762 : Windowed {
3763 12 : attrs,
3764 12 : max_per_attr: times.len(),
3765 12 : ts_min: times.first().map(|s| (*s).to_owned()),
3766 12 : ts_max: times.last().map(|s| (*s).to_owned()),
3767 : truncated: false,
3768 : }
3769 12 : }
3770 :
3771 20 : fn repr(duration: &str) -> TRepr {
3772 20 : TRepr {
3773 20 : aggregated: true,
3774 20 : aggr_methods: vec!["totalCount".to_owned()],
3775 20 : aggr_period: parse_iso_duration(duration).expect("duration"),
3776 20 : ..Default::default()
3777 20 : }
3778 20 : }
3779 :
3780 : /// The periods of an aggregated response are the periods "in the time
3781 : /// range of the query" (4.5.19.0), so with `timerel=before` they run
3782 : /// backwards from `timeAt` and every returned period contains the
3783 : /// instances aggregated into it.
3784 : #[tokio::test]
3785 4 : async fn month_periods_before_the_anchor_contain_their_instances() {
3786 4 : let w = windowed(&[
3787 4 : "2020-01-15T00:00:00Z",
3788 4 : "2020-02-15T00:00:00Z",
3789 4 : "2020-03-15T00:00:00Z",
3790 4 : ]);
3791 4 : let tq = TemporalQ {
3792 4 : timerel: "before".to_owned(),
3793 4 : time_at: "2020-04-01T00:00:00Z".to_owned(),
3794 4 : end_time_at: None,
3795 4 : timeproperty: "observedAt".to_owned(),
3796 4 : };
3797 4 : let out = render_aggregated(
3798 4 : &w,
3799 4 : Some(&tq),
3800 4 : &repr("P1M"),
3801 4 : &antares_jsonld::Context::default(),
3802 4 : "observedAt",
3803 : )
3804 4 : .expect("aggregated");
3805 4 : let rows = out["speed"]["totalCount"].as_array().expect("rows").clone();
3806 4 : assert_eq!(
3807 : rows,
3808 4 : vec![
3809 4 : json!([1, "2020-01-01T00:00:00Z", "2020-02-01T00:00:00Z"]),
3810 4 : json!([1, "2020-02-01T00:00:00Z", "2020-03-01T00:00:00Z"]),
3811 4 : json!([1, "2020-03-01T00:00:00Z", "2020-04-01T00:00:00Z"]),
3812 : ]
3813 : );
3814 : // the negative assertion: no period may start at the anchor, since
3815 : // such a period holds none of the instances of a `before` query
3816 4 : assert!(
3817 4 : !Value::Array(rows)
3818 4 : .to_string()
3819 4 : .contains("2020-04-01T00:00:00Z\",\""),
3820 4 : "a period starting at timeAt contains no instance of a before query"
3821 4 : );
3822 4 : }
3823 :
3824 : /// 4.5.19.1: "A duration of 0 second (e.g. expressed as "PT0S" or
3825 : /// "P0D") is valid and is interpreted as a duration spanning the whole
3826 : /// time range specified by the temporal query." The query names one
3827 : /// edge of that range and 4.11 leaves the other open, so the period
3828 : /// runs from `timeAt` only when `timeAt` is where the range starts.
3829 : #[tokio::test]
3830 4 : async fn the_zero_duration_period_spans_the_time_range_the_query_asked_for() {
3831 4 : let w = windowed(&["2020-09-01T12:03:00Z", "2020-09-01T12:05:00Z"]);
3832 12 : let one = |tq: &TemporalQ| {
3833 12 : let out = render_aggregated(
3834 12 : &w,
3835 12 : Some(tq),
3836 12 : &repr("PT0S"),
3837 12 : &antares_jsonld::Context::default(),
3838 12 : "observedAt",
3839 : )
3840 12 : .expect("aggregated");
3841 12 : out["speed"]["totalCount"].as_array().expect("rows").clone()
3842 12 : };
3843 :
3844 : // before: timeAt ENDS the range (4.11 makes the start open, so the
3845 : // data supplies it). The period must not run backwards.
3846 4 : assert_eq!(
3847 4 : one(&TemporalQ {
3848 4 : timerel: "before".to_owned(),
3849 4 : time_at: "2030-01-01T00:00:00Z".to_owned(),
3850 4 : end_time_at: None,
3851 4 : timeproperty: "observedAt".to_owned(),
3852 4 : }),
3853 4 : vec![json!([2, "2020-09-01T12:03:00Z", "2030-01-01T00:00:00Z"])]
3854 : );
3855 :
3856 : // between: both edges are named, and the period is exactly them —
3857 : // not the last instant the data happens to hold.
3858 4 : assert_eq!(
3859 4 : one(&TemporalQ {
3860 4 : timerel: "between".to_owned(),
3861 4 : time_at: "2020-09-01T12:00:00Z".to_owned(),
3862 4 : end_time_at: Some("2020-09-01T13:00:00Z".to_owned()),
3863 4 : timeproperty: "observedAt".to_owned(),
3864 4 : }),
3865 4 : vec![json!([2, "2020-09-01T12:00:00Z", "2020-09-01T13:00:00Z"])]
3866 : );
3867 :
3868 : // after: timeAt STARTS the range, the data closes it.
3869 4 : assert_eq!(
3870 4 : one(&TemporalQ {
3871 4 : timerel: "after".to_owned(),
3872 4 : time_at: "2020-01-01T00:00:00Z".to_owned(),
3873 4 : end_time_at: None,
3874 4 : timeproperty: "observedAt".to_owned(),
3875 4 : }),
3876 4 : vec![json!([2, "2020-01-01T00:00:00Z", "2020-09-01T12:05:01Z"])]
3877 4 : );
3878 4 : }
3879 :
3880 : /// A mixed period steps by its months AND its seconds: "P1MT12H" from
3881 : /// the anchor ends one month and twelve hours later (4.5.19.1).
3882 : #[tokio::test]
3883 4 : async fn a_mixed_period_steps_by_both_components() {
3884 4 : let w = windowed(&["2020-01-01T06:00:00Z", "2020-02-02T06:00:00Z"]);
3885 4 : let tq = TemporalQ {
3886 4 : timerel: "after".to_owned(),
3887 4 : time_at: "2020-01-01T00:00:00Z".to_owned(),
3888 4 : end_time_at: None,
3889 4 : timeproperty: "observedAt".to_owned(),
3890 4 : };
3891 4 : let out = render_aggregated(
3892 4 : &w,
3893 4 : Some(&tq),
3894 4 : &repr("P1MT12H"),
3895 4 : &antares_jsonld::Context::default(),
3896 4 : "observedAt",
3897 : )
3898 4 : .expect("aggregated");
3899 4 : assert_eq!(
3900 4 : out["speed"]["totalCount"],
3901 4 : json!([
3902 4 : [1, "2020-01-01T00:00:00Z", "2020-02-01T12:00:00Z"],
3903 4 : [1, "2020-02-01T12:00:00Z", "2020-03-02T00:00:00Z"],
3904 4 : ])
3905 4 : );
3906 4 : }
3907 :
3908 : /// One attribute whose instances carry the given members, an hour apart.
3909 48 : fn windowed_props(members: &[Value]) -> Windowed {
3910 48 : let instances: Vec<Value> = members
3911 48 : .iter()
3912 48 : .enumerate()
3913 92 : .map(|(i, m)| {
3914 92 : let mut inst = json!({
3915 92 : "type": "Property",
3916 92 : "observedAt": format!("2020-01-01T{i:02}:00:00Z"),
3917 : });
3918 100 : for (k, v) in m.as_object().expect("instance members") {
3919 100 : inst[k] = v.clone();
3920 100 : }
3921 92 : inst
3922 92 : })
3923 48 : .collect();
3924 48 : let times: Vec<String> = instances
3925 48 : .iter()
3926 92 : .map(|i| i["observedAt"].as_str().unwrap_or_default().to_owned())
3927 48 : .collect();
3928 48 : let mut attrs = std::collections::BTreeMap::new();
3929 48 : attrs.insert("speed".to_owned(), instances);
3930 48 : Windowed {
3931 48 : attrs,
3932 48 : max_per_attr: members.len(),
3933 48 : ts_min: times.first().cloned(),
3934 48 : ts_max: times.last().cloned(),
3935 48 : truncated: false,
3936 48 : }
3937 48 : }
3938 :
3939 72 : fn aggregate(w: &Windowed, methods: &[&str]) -> Result<Map<String, Value>, NgsiError> {
3940 72 : let r = TRepr {
3941 : aggregated: true,
3942 116 : aggr_methods: methods.iter().map(|m| (*m).to_string()).collect(),
3943 72 : ..Default::default()
3944 : };
3945 72 : render_aggregated(
3946 72 : w,
3947 72 : None,
3948 72 : &r,
3949 72 : &antares_jsonld::Context::default(),
3950 72 : "observedAt",
3951 : )
3952 72 : }
3953 :
3954 : /// Table 4.5.19.1-2: on a DateTime and on a Date, `min` "calculates the
3955 : /// minimum value inside the period" and `max` the maximum. Both
3956 : /// datatypes reach the broker as a JSON-LD typed value — C.6's
3957 : /// `{"@type": "DateTime", "@value": "2018-12-04T12:00:00Z"}` — so the
3958 : /// aggregation reads the value through its type instead of treating the
3959 : /// wrapper as an opaque object.
3960 : #[test]
3961 4 : fn a_date_time_and_a_date_have_a_minimum_and_a_maximum() {
3962 12 : let dt = |v: &str| json!({"value": {"@type": "DateTime", "@value": v}});
3963 4 : let out = aggregate(
3964 4 : &windowed_props(&[
3965 4 : dt("2020-03-01T00:00:00Z"),
3966 4 : dt("2020-01-01T00:00:00Z"),
3967 4 : dt("2020-02-01T00:00:00Z"),
3968 4 : ]),
3969 4 : &["min", "max"],
3970 : )
3971 4 : .expect("DateTime is eligible for min and max");
3972 4 : assert_eq!(
3973 4 : out["speed"]["min"][0][0],
3974 4 : json!({"@type": "DateTime", "@value": "2020-01-01T00:00:00Z"})
3975 : );
3976 4 : assert_eq!(
3977 4 : out["speed"]["max"][0][0],
3978 4 : json!({"@type": "DateTime", "@value": "2020-03-01T00:00:00Z"})
3979 : );
3980 :
3981 8 : let d = |v: &str| json!({"value": {"@type": "Date", "@value": v}});
3982 4 : let out = aggregate(
3983 4 : &windowed_props(&[d("2020-03-01"), d("2020-01-01")]),
3984 4 : &["min", "max"],
3985 : )
3986 4 : .expect("Date is eligible for min and max");
3987 4 : assert_eq!(
3988 4 : out["speed"]["min"][0][0],
3989 4 : json!({"@type": "Date", "@value": "2020-01-01"})
3990 : );
3991 4 : assert_eq!(
3992 4 : out["speed"]["max"][0][0],
3993 4 : json!({"@type": "Date", "@value": "2020-03-01"})
3994 : );
3995 4 : }
3996 :
3997 : /// C.6 gives a second representation of the same datatypes: the value
3998 : /// stays a string and `valueType` (4.5.2.2) carries the type, coerced to
3999 : /// its datatype URI on the way in. Table 4.5.19.1-2 applies to the
4000 : /// datatype, not to the spelling, so this form aggregates identically.
4001 : #[test]
4002 4 : fn a_value_type_carries_the_datatype_as_far_as_the_typed_value_does() {
4003 4 : let dt =
4004 8 : |v: &str| json!({"value": v, "valueType": "https://uri.etsi.org/ngsi-ld/DateTime"});
4005 4 : let out = aggregate(
4006 4 : &windowed_props(&[dt("2020-03-01T00:00:00Z"), dt("2020-01-01T00:00:00Z")]),
4007 4 : &["min", "max"],
4008 : )
4009 4 : .expect("a valueType-coerced DateTime is eligible for min and max");
4010 4 : assert_eq!(out["speed"]["min"][0][0], json!("2020-01-01T00:00:00Z"));
4011 4 : assert_eq!(out["speed"]["max"][0][0], json!("2020-03-01T00:00:00Z"));
4012 4 : }
4013 :
4014 : /// Table 4.5.19.1-2, Time column: `avg` "calculates the average time
4015 : /// inside the period", and min/max apply as well. 4.6.3 mandates
4016 : /// `hh:mm:ssZ` for a Time, so the computed average is one — carrying its
4017 : /// type, since a bare string would read back as a JSON String, whose own
4018 : /// column in Table 4.5.19.1-1 has no average at all.
4019 : #[test]
4020 4 : fn a_time_has_an_average_a_minimum_and_a_maximum() {
4021 8 : let t = |v: &str| json!({"value": {"@type": "Time", "@value": v}});
4022 4 : let out = aggregate(
4023 4 : &windowed_props(&[t("09:30:00Z"), t("08:30:00Z")]),
4024 4 : &["avg", "min", "max"],
4025 : )
4026 4 : .expect("Time is eligible for avg, min and max");
4027 4 : assert_eq!(
4028 4 : out["speed"]["avg"][0][0],
4029 4 : json!({"@type": "Time", "@value": "09:00:00Z"})
4030 : );
4031 4 : assert_eq!(
4032 4 : out["speed"]["min"][0][0],
4033 4 : json!({"@type": "Time", "@value": "08:30:00Z"})
4034 : );
4035 4 : assert_eq!(
4036 4 : out["speed"]["max"][0][0],
4037 4 : json!({"@type": "Time", "@value": "09:30:00Z"})
4038 : );
4039 4 : }
4040 :
4041 : /// The N/A cells of Table 4.5.19.1-2 are refused, not computed: 5.7.4.4
4042 : /// p.211 raises InvalidRequest when an Attribute "is not eligible for at
4043 : /// least one of the aggregation methods specified in the request".
4044 : /// DateTime and Date have no avg, sum, stddev or sumsq; Time has no sum,
4045 : /// stddev or sumsq.
4046 : #[test]
4047 4 : fn the_methods_a_temporal_datatype_does_not_support_are_refused() {
4048 4 : let w = windowed_props(&[
4049 4 : json!({"value": {"@type": "DateTime", "@value": "2020-01-01T00:00:00Z"}}),
4050 4 : ]);
4051 16 : for method in ["avg", "sum", "stddev", "sumsq"] {
4052 16 : assert!(
4053 16 : matches!(aggregate(&w, &[method]), Err(NgsiError::InvalidRequest(_))),
4054 : "DateTime must not be eligible for {method}"
4055 : );
4056 : }
4057 4 : let w = windowed_props(&[json!({"value": {"@type": "Time", "@value": "08:30:00Z"}})]);
4058 12 : for method in ["sum", "stddev", "sumsq"] {
4059 12 : assert!(
4060 12 : matches!(aggregate(&w, &[method]), Err(NgsiError::InvalidRequest(_))),
4061 : "Time must not be eligible for {method}"
4062 : );
4063 : }
4064 4 : }
4065 :
4066 : /// Table 4.5.19.1-1, JSON String column: `avg` is N/A. A string is a
4067 : /// JSON String whatever it spells, so a value that reads like a
4068 : /// time-of-day is averaged only when its datatype says it is a Time.
4069 : #[test]
4070 4 : fn a_json_string_has_no_average_however_it_reads() {
4071 4 : let w = windowed_props(&[json!({"value": "08:30:00Z"}), json!({"value": "09:30:00Z"})]);
4072 4 : assert!(matches!(
4073 4 : aggregate(&w, &["avg"]),
4074 : Err(NgsiError::InvalidRequest(_))
4075 : ));
4076 : // its own row of the table is unchanged: lexicographic min and max
4077 4 : let out = aggregate(&w, &["min", "max"]).expect("a string has min and max");
4078 4 : assert_eq!(out["speed"]["min"][0][0], json!("08:30:00Z"));
4079 4 : assert_eq!(out["speed"]["max"][0][0], json!("09:30:00Z"));
4080 4 : }
4081 :
4082 : /// The counting methods have no N/A cell in any of the three tables:
4083 : /// `totalCount` "the number of times the value has been updated" and
4084 : /// `distinctCount` "the count of distinct values", for every datatype
4085 : /// including the ones with no other method at all.
4086 : #[test]
4087 4 : fn every_datatype_is_counted() {
4088 20 : for members in [
4089 4 : json!({"value": {"@type": "DateTime", "@value": "2020-01-01T00:00:00Z"}}),
4090 4 : json!({"value": {"@type": "Date", "@value": "2020-01-01"}}),
4091 4 : json!({"value": {"@type": "Time", "@value": "08:30:00Z"}}),
4092 4 : json!({"vocab": "urn:ngsi-ld:Colour:red"}),
4093 4 : json!({"object": "urn:ngsi-ld:Car:1"}),
4094 4 : ] {
4095 20 : let w = windowed_props(&[members.clone(), members.clone()]);
4096 20 : let out = aggregate(&w, &["totalCount", "distinctCount"])
4097 20 : .unwrap_or_else(|e| panic!("{members} must be counted: {e:?}"));
4098 20 : assert_eq!(out["speed"]["totalCount"][0][0], json!(2));
4099 20 : assert_eq!(out["speed"]["distinctCount"][0][0], json!(1));
4100 : }
4101 4 : }
4102 : }
4103 :
4104 : #[cfg(test)]
4105 : mod clause_4_21 {
4106 : use super::*;
4107 : use antares_jsonld::Loader;
4108 :
4109 28 : fn params(kv: &[(&str, &str)]) -> HashMap<String, String> {
4110 28 : kv.iter()
4111 48 : .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
4112 28 : .collect()
4113 28 : }
4114 :
4115 : /// 4.21 Projections: "pick, omit and attrs are mutually exclusive" holds
4116 : /// on the temporal representation exactly as it does on the current-state
4117 : /// one — the temporal operations define no exception to it, so any pair
4118 : /// is BadRequestData and each one alone parses.
4119 : #[test]
4120 4 : fn pick_omit_and_attrs_cannot_be_combined_on_a_temporal_query() {
4121 4 : let ctx = Loader::new().core();
4122 16 : for p in [
4123 4 : params(&[("pick", "a"), ("omit", "b")]),
4124 4 : params(&[("pick", "a"), ("attrs", "b")]),
4125 4 : params(&[("omit", "a"), ("attrs", "b")]),
4126 4 : params(&[("pick", "a"), ("omit", "b"), ("attrs", "c")]),
4127 4 : ] {
4128 16 : match parse_trepr(&p, &ctx) {
4129 16 : Err(NgsiError::BadRequestData(_)) => {}
4130 0 : other => panic!("must be BadRequestData, got {:?}", other.err()),
4131 : }
4132 : }
4133 12 : for p in [
4134 4 : params(&[("pick", "a")]),
4135 4 : params(&[("omit", "a")]),
4136 4 : params(&[("attrs", "a")]),
4137 4 : ] {
4138 12 : assert!(parse_trepr(&p, &ctx).is_ok());
4139 : }
4140 4 : }
4141 :
4142 : /// 4.21 on the core members of a temporal Entity: `pick` constrains them
4143 : /// strictly (only what is named survives) and `omit` drops a named member
4144 : /// only when the node carries no children — the same reading the
4145 : /// current-state representation applies, so the two never disagree about
4146 : /// whether `id` or `type` is in the answer.
4147 : #[test]
4148 4 : fn core_members_follow_the_same_projection_rule() {
4149 4 : let pick = crate::repr::parse_projection("id", &Loader::new().core()).expect("pick");
4150 4 : assert!(crate::repr::meta_projected(Some(&pick), None, "id"));
4151 4 : assert!(!crate::repr::meta_projected(Some(&pick), None, "type"));
4152 4 : let omit = crate::repr::parse_projection("type", &Loader::new().core()).expect("omit");
4153 4 : assert!(!crate::repr::meta_projected(None, Some(&omit), "type"));
4154 4 : assert!(crate::repr::meta_projected(None, Some(&omit), "id"));
4155 4 : assert!(crate::repr::meta_projected(None, None, "type"));
4156 4 : }
4157 : }
|