Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! Paging, ordering and the query-body parameters shared by every list
3 : //! operation: the limit/offset/count triple and the next/prev links
4 : //! (4.12, 6.3.10), entity ordering and ICU collation (4.23), the
5 : //! `NGSILD-Warning` header (6.3.17) and the POST /entityOperations/query
6 : //! body lifted into the same parameters its GET twin carries (5.2.23).
7 :
8 : use crate::negotiate::{Accept, ApiResult};
9 : use crate::state::AppState;
10 : use antares_model::NgsiError;
11 : use axum::response::Response;
12 : use serde_json::{Map, Value};
13 : use std::collections::HashMap;
14 :
15 : /// 6.3.17: one `NGSILD-Warning` header per abnormal distributed-GET outcome —
16 : /// scoped by the clause to /entities and /entities/{id}.
17 1670 : pub fn attach_warnings(resp: &mut Response, warnings: &[String]) {
18 1670 : for w in warnings {
19 84 : if let Ok(v) = axum::http::HeaderValue::from_str(w) {
20 84 : resp.headers_mut().append("NGSILD-Warning", v);
21 84 : }
22 : }
23 1670 : }
24 :
25 : /// limit/offset/count handling (6.3.10). Returns (page, count, link headers).
26 : /// 4.12/5.5.9.1 Pagination: L = client limit (Mc) or the default (Md); at
27 : /// most L elements per page; remaining elements are flagged with a next
28 : /// pointer carrying every parameter needed to fetch the page, prev on every
29 : /// iteration but the first, and only prev on the last. Shared by every
30 : /// paginated list operation (5.7.2, 5.7.4, 5.8.4, 5.10.2, 5.11.5).
31 992 : pub fn paginate(
32 992 : st: &AppState,
33 992 : params: &HashMap<String, String>,
34 992 : matches: Vec<Value>,
35 992 : path: &str,
36 992 : ) -> ApiResult<(Vec<Value>, Option<usize>, Vec<String>)> {
37 992 : paginate_impl(st, params, matches, path, Accept::Json, None)
38 992 : }
39 :
40 : /// The store already applied ORDER BY id + LIMIT/OFFSET and counted the
41 : /// match set — `matches` IS the page; only count/links remain.
42 136 : pub fn paginate_pre(
43 136 : st: &AppState,
44 136 : params: &HashMap<String, String>,
45 136 : page: Vec<Value>,
46 136 : path: &str,
47 136 : total: usize,
48 136 : ) -> ApiResult<(Vec<Value>, Option<usize>, Vec<String>)> {
49 136 : paginate_impl(st, params, page, path, Accept::Json, Some(total))
50 136 : }
51 :
52 : /// `paginate_pre` for a caller that negotiated a media type: the store
53 : /// already applied ORDER BY id + LIMIT/OFFSET and counted the match set.
54 120 : pub fn paginate_pre_accept(
55 120 : st: &AppState,
56 120 : params: &HashMap<String, String>,
57 120 : page: Vec<Value>,
58 120 : path: &str,
59 120 : accept: Accept,
60 120 : total: usize,
61 120 : ) -> ApiResult<(Vec<Value>, Option<usize>, Vec<String>)> {
62 120 : paginate_impl(st, params, page, path, accept, Some(total))
63 120 : }
64 :
65 : /// 4.12 Pagination: clients specify a limit (page size), the server defines
66 : /// a default page size, and a hard ceiling is rejected with TooManyResults
67 : /// rather than silently clamped. The limit/offset/count triple of 6.3.10,
68 : /// validated (ceilings included). Shared by `paginate_impl` and the
69 : /// pushdown gate so the two paths can never disagree on what a page is.
70 2530 : pub fn page_params(
71 2530 : st: &AppState,
72 2530 : params: &HashMap<String, String>,
73 2530 : ) -> ApiResult<(usize, usize, bool)> {
74 2530 : let count = params.get("count").map(String::as_str) == Some("true");
75 2530 : let limit: usize = match params.get("limit") {
76 642 : Some(l) => l
77 642 : .parse()
78 642 : .map_err(|_| NgsiError::BadRequestData(format!("invalid limit {l:?}")))?,
79 1888 : None => st.default_limit,
80 : };
81 : // 5.5.6: "so many results that can potentially exhaust client or server
82 : // resources" — the implementation threshold is max_limit; 403
83 : // TooManyResults, not silent clamping.
84 2502 : if limit > st.max_limit {
85 12 : return Err(NgsiError::TooManyResults(format!(
86 12 : "limit {limit} exceeds the server maximum {}",
87 12 : st.max_limit
88 12 : ))
89 12 : .into());
90 2490 : }
91 2490 : if limit == 0 && !count {
92 12 : return Err(
93 12 : NgsiError::BadRequestData("limit=0 requires count=true (6.3.10)".into()).into(),
94 12 : );
95 2478 : }
96 2478 : let offset: usize = match params.get("offset") {
97 176 : Some(o) => o
98 176 : .parse()
99 176 : .map_err(|_| NgsiError::BadRequestData(format!("invalid offset {o:?}")))?,
100 2302 : None => 0,
101 : };
102 : // An offset above i64::MAX wraps negative when bound as SQL `$n::bigint`
103 : // (Postgres then rejects a negative OFFSET → 500). Reject it as a bad
104 : // precondition instead.
105 2450 : if offset > i64::MAX as usize {
106 4 : return Err(NgsiError::BadRequestData(format!("offset {offset} is out of range")).into());
107 2446 : }
108 2446 : Ok((offset, limit, count))
109 2530 : }
110 :
111 : /// 6.3.10: next/prev Links carry the response media type; the suite asserts
112 : /// `;type="application/ld+json"` on ld+json list responses (031_02).
113 54 : pub fn paginate_accept(
114 54 : st: &AppState,
115 54 : params: &HashMap<String, String>,
116 54 : matches: Vec<Value>,
117 54 : path: &str,
118 54 : accept: Accept,
119 54 : ) -> ApiResult<(Vec<Value>, Option<usize>, Vec<String>)> {
120 54 : paginate_impl(st, params, matches, path, accept, None)
121 54 : }
122 :
123 1302 : fn paginate_impl(
124 1302 : st: &AppState,
125 1302 : params: &HashMap<String, String>,
126 1302 : matches: Vec<Value>,
127 1302 : path: &str,
128 1302 : accept: Accept,
129 1302 : pre: Option<usize>,
130 1302 : ) -> ApiResult<(Vec<Value>, Option<usize>, Vec<String>)> {
131 1302 : let (offset, limit, count) = page_params(st, params)?;
132 1294 : let total = pre.unwrap_or(matches.len());
133 1294 : let page: Vec<Value> = match pre {
134 256 : Some(_) => matches, // already exactly the page (store pushdown)
135 1038 : None => matches.into_iter().skip(offset).take(limit).collect(),
136 : };
137 1294 : let mut links = Vec::new();
138 : // csource resources: the suite string-compares links against
139 : // `?other…&limit=N&offset=M` order with an unconditional ld+json type
140 : // suffix (037_11, 041_03); entity lists keep sorted params + accept-based
141 : // suffix (031_02).
142 1294 : let csource_style = path.contains("csource");
143 1294 : let mut mk = |off: usize, rel: &str| {
144 : let mut qp: Vec<String>;
145 116 : if csource_style {
146 22 : qp = params
147 22 : .iter()
148 72 : .filter(|(k, _)| !matches!(k.as_str(), "offset" | "limit"))
149 28 : .map(|(k, v)| format!("{k}={}", query_value(v)))
150 22 : .collect();
151 22 : qp.sort();
152 22 : if let Some(l) = params.get("limit") {
153 22 : qp.push(format!("limit={l}"));
154 22 : }
155 22 : qp.push(format!("offset={off}"));
156 : } else {
157 94 : qp = params
158 94 : .iter()
159 346 : .filter(|(k, _)| k.as_str() != "offset")
160 270 : .map(|(k, v)| format!("{k}={}", query_value(v)))
161 94 : .collect();
162 94 : qp.push(format!("offset={off}"));
163 94 : qp.sort(); // deterministic order — the suite string-compares links
164 : }
165 : // 6.3.10: "At least, the type Link Target Attribute shall be included
166 : // ... and its value shall be exactly equal to the media type resulting
167 : // from the original request" — for EVERY media type, not just ld+json.
168 116 : let ty = match accept {
169 22 : _ if csource_style => ";type=\"application/ld+json\"",
170 0 : Accept::LdJson => ";type=\"application/ld+json\"",
171 94 : Accept::Json => ";type=\"application/json\"",
172 0 : Accept::GeoJson => ";type=\"application/geo+json\"",
173 : };
174 116 : links.push(format!("<{path}?{}>; rel=\"{rel}\"{ty}", qp.join("&")));
175 116 : };
176 1294 : if offset + limit < total && limit > 0 {
177 56 : mk(offset + limit, "next");
178 1238 : }
179 1294 : if offset > 0 {
180 60 : mk(offset.saturating_sub(limit.max(1)), "prev");
181 1234 : }
182 1294 : Ok((page, count.then_some(total), links))
183 1302 : }
184 :
185 : /// Sort by an orderBy spec: comma-separated `member[;asc|desc]`.
186 : /// 4.23 Entity Ordering: orderBy = `AttrName[;direction] *(, …)` with asc
187 : /// (default) / desc / dist-asc / dist-desc (4.23.3); distance keys need the
188 : /// orderFrom reference coordinates (orderGeometry, default Point) and apply
189 : /// to GeoProperties — non-GeoProperties fall back to value order after them
190 : /// (4.23.2). Mixed datatypes rank Numbers < Strings < Object < Array <
191 : /// Boolean < Time < Date < DateTime < Null < absent (4.23.2). Paths may be
192 : /// dotted (EXAMPLE 5) or carry one trailing [member.path] bracket
193 : /// (EXAMPLE 4). String comparison is codepoint order by default; the
194 : /// `collation` parameter selects an ICU collation (4.23.3 EXAMPLES 6/7).
195 : ///
196 : /// 4.23.3 EXAMPLES 6/7: the ICU collator for an RFC 6067 collation tag
197 : /// (e.g. und-u-ks-identic, de-u-co-phonebk). The co/kf/kn keywords travel
198 : /// via CollatorPreferences; the -u-ks strength keyword maps onto
199 : /// CollatorOptions. Invalid/unsupported tags are BadRequestData.
200 : /// 5.7.2.4 / 5.7.4.4: "If a preferred collation setting is present and it
201 : /// does not conform to a valid ICU collation (see IETF RFC 6067 \[36\]) then an
202 : /// error of type BadRequestData shall be raised." The clause names the
203 : /// parameter's presence, not an `orderBy` that happens to consume it, so the
204 : /// check runs on every operation that accepts `collation`.
205 1138 : pub fn check_collation(params: &HashMap<String, String>) -> Result<(), NgsiError> {
206 1138 : match params.get("collation") {
207 18 : Some(tag) => build_collator(tag).map(|_| ()),
208 1120 : None => Ok(()),
209 : }
210 1138 : }
211 :
212 44 : fn build_collator(tag: &str) -> Result<icu_collator::CollatorBorrowed<'static>, NgsiError> {
213 44 : let bad = NgsiError::BadRequestData;
214 44 : let locale: icu_locale_core::Locale = tag.parse().map_err(|_| {
215 14 : bad(format!(
216 14 : "collation is not an RFC 6067 tag: {tag:?} (4.23.3)"
217 14 : ))
218 14 : })?;
219 30 : let mut opts = icu_collator::options::CollatorOptions::default();
220 : use icu_collator::options::Strength;
221 : use icu_locale_core::extensions::unicode::key;
222 30 : if let Some(ks) = locale.extensions.unicode.keywords.get(&key!("ks")) {
223 4 : opts.strength = Some(match ks.to_string().as_str() {
224 4 : "level1" => Strength::Primary,
225 0 : "level2" => Strength::Secondary,
226 0 : "level3" => Strength::Tertiary,
227 0 : "level4" => Strength::Quaternary,
228 0 : "identic" => Strength::Identical,
229 0 : other => {
230 0 : return Err(bad(format!(
231 0 : "unknown collation strength {other:?} (4.23.3)"
232 0 : )))
233 : }
234 : });
235 26 : }
236 30 : icu_collator::Collator::try_new((&locale).into(), opts)
237 30 : .map_err(|_| bad(format!("unsupported collation {tag:?} (4.23.3)")))
238 44 : }
239 :
240 108 : pub fn order_entities(
241 108 : docs: &mut [Value],
242 108 : spec: &str,
243 108 : params: &HashMap<String, String>,
244 108 : ctx: &antares_jsonld::Context,
245 108 : ) -> Result<(), NgsiError> {
246 : #[derive(PartialEq)]
247 : enum Dir {
248 : Asc,
249 : Desc,
250 : DistAsc,
251 : DistDesc,
252 : }
253 : struct Key {
254 : path: Vec<String>,
255 : bracket: Option<Vec<String>>,
256 : dir: Dir,
257 : }
258 108 : let bad = NgsiError::BadRequestData;
259 108 : let mut keys = Vec::new();
260 116 : for part in spec.split(',') {
261 116 : let part = part.trim();
262 116 : let (member, dir) = match part.split_once(';') {
263 60 : Some((m, d)) => (m.trim(), d.trim()),
264 56 : None => (part, "asc"),
265 : };
266 116 : let dir = match dir {
267 116 : "asc" => Dir::Asc,
268 48 : "desc" => Dir::Desc,
269 44 : "dist-asc" => Dir::DistAsc,
270 6 : "dist-desc" => Dir::DistDesc,
271 : _ => {
272 0 : return Err(bad(format!(
273 0 : "invalid orderBy direction in {spec:?} (4.23.3)"
274 0 : )))
275 : }
276 : };
277 : // one trailing [member.path] bracket (EXAMPLE 4)
278 116 : let (head, bracket) = match member.split_once('[') {
279 4 : Some((h, rest)) => {
280 4 : let inner = rest
281 4 : .strip_suffix(']')
282 4 : .ok_or_else(|| bad(format!("unclosed bracket in orderBy {spec:?}")))?;
283 4 : (h, Some(inner.split('.').map(str::to_owned).collect()))
284 : }
285 112 : None => (member, None),
286 : };
287 116 : if head.is_empty() {
288 0 : return Err(bad(format!("invalid orderBy {spec:?} (4.23)")));
289 116 : }
290 116 : keys.push(Key {
291 116 : path: head.split('.').map(str::to_owned).collect(),
292 116 : bracket,
293 116 : dir,
294 116 : });
295 : }
296 : // 4.23.3 EXAMPLES 6/7: collation names an ICU ordering for strings
297 108 : let collator = params
298 108 : .get("collation")
299 108 : .map(|t| build_collator(t))
300 108 : .transpose()?;
301 : // dist-* keys need the orderFrom reference geometry (4.23.3 EXAMPLE 8-10)
302 104 : let refg = if keys
303 104 : .iter()
304 112 : .any(|k| matches!(k.dir, Dir::DistAsc | Dir::DistDesc))
305 : {
306 44 : let coords_raw = params
307 44 : .get("orderFrom")
308 44 : .ok_or_else(|| bad("dist ordering requires orderFrom (4.23.3)".into()))?;
309 30 : let coords: Value = serde_json::from_str(coords_raw)
310 30 : .map_err(|_| bad(format!("invalid orderFrom {coords_raw:?}")))?;
311 30 : let gtype = params
312 30 : .get("orderGeometry")
313 30 : .cloned()
314 30 : .unwrap_or_else(|| "Point".into());
315 30 : Some(antares_ql::geo::parse_ref_geometry(>ype, &coords).map_err(bad)?)
316 : } else {
317 60 : None
318 : };
319 540 : fn order_value(doc: &Value, k: &Key, ctx: &antares_jsonld::Context) -> Option<Value> {
320 540 : let path = &k.path;
321 540 : let head = path.first()?;
322 540 : let base = match head.as_str() {
323 540 : "id" | "createdAt" | "modifiedAt" => doc.get(head.as_str()).cloned(),
324 532 : "type" => doc["type"].as_array().and_then(|a| a.first()).cloned(),
325 : _ => {
326 532 : let iri = ctx.expand_key(head);
327 532 : let inst = doc.get(&iri).and_then(Value::as_array)?.first()?;
328 504 : let mut cur = inst;
329 504 : for seg in &path[1..] {
330 0 : match seg.as_str() {
331 0 : "createdAt" | "modifiedAt" | "observedAt" | "datasetId" | "unitCode" => {
332 0 : cur = cur.get(seg.as_str())?;
333 : }
334 : _ => {
335 0 : let siri = ctx.expand_key(seg);
336 0 : cur = cur
337 0 : .get(&siri)
338 0 : .and_then(Value::as_array)
339 0 : .and_then(|a| a.first())?;
340 : }
341 : }
342 : }
343 504 : match cur.get("value").or_else(|| cur.get("object")) {
344 504 : Some(v) => Some(v.clone()),
345 0 : None => Some(cur.clone()),
346 : }
347 : }
348 0 : }?;
349 512 : match &k.bracket {
350 504 : None => Some(base),
351 8 : Some(b) => {
352 8 : let mut cur = &base;
353 8 : for seg in b {
354 8 : cur = cur.get(seg)?;
355 : }
356 8 : Some(cur.clone())
357 : }
358 : }
359 540 : }
360 : /// 4.23.2 datatype rank: Numbers < Strings < Object < Array < Boolean <
361 : /// Time < Date < DateTime < Null (absent is handled as Option::None).
362 376 : fn rank(v: &Value) -> u8 {
363 376 : match v {
364 36 : Value::Number(_) => 0,
365 224 : Value::String(s) => {
366 224 : if antares_jsonld::parse_datetime(s) {
367 32 : 7
368 192 : } else if is_date(s) {
369 24 : 6
370 168 : } else if is_time(s) {
371 16 : 5
372 : } else {
373 152 : 1
374 : }
375 : }
376 28 : Value::Object(_) => 2,
377 24 : Value::Array(_) => 3,
378 32 : Value::Bool(_) => 4,
379 32 : Value::Null => 8,
380 : }
381 376 : }
382 : /// 4.6.3 Date: YYYY-MM-DD, all components present.
383 192 : fn is_date(s: &str) -> bool {
384 192 : let b = s.as_bytes();
385 192 : b.len() == 10
386 24 : && b[4] == b'-'
387 24 : && b[7] == b'-'
388 24 : && b.iter()
389 24 : .enumerate()
390 240 : .all(|(i, c)| matches!(i, 4 | 7) || c.is_ascii_digit())
391 192 : }
392 : /// 4.6.3 Time: hh:mm:ss[.f*]Z.
393 168 : fn is_time(s: &str) -> bool {
394 168 : let b = s.as_bytes();
395 168 : b.len() >= 9
396 28 : && b[b.len() - 1] == b'Z'
397 16 : && b[2] == b':'
398 16 : && b[5] == b':'
399 16 : && b[..2].iter().all(u8::is_ascii_digit)
400 16 : && b[3..5].iter().all(u8::is_ascii_digit)
401 16 : && b[6..8].iter().all(u8::is_ascii_digit)
402 168 : }
403 216 : fn cmp_vals(
404 216 : a: &Option<Value>,
405 216 : b: &Option<Value>,
406 216 : coll: Option<&icu_collator::CollatorBorrowed<'static>>,
407 216 : ) -> std::cmp::Ordering {
408 : use std::cmp::Ordering;
409 216 : match (a, b) {
410 0 : (None, None) => Ordering::Equal,
411 4 : (None, Some(_)) => Ordering::Greater, // absent sorts last (4.23.2)
412 24 : (Some(_), None) => Ordering::Less,
413 188 : (Some(x), Some(y)) => {
414 188 : let (rx, ry) = (rank(x), rank(y));
415 188 : if rx != ry {
416 120 : return rx.cmp(&ry);
417 68 : }
418 68 : match (x, y) {
419 4 : (Value::Number(_), Value::Number(_)) => x
420 4 : .as_f64()
421 4 : .unwrap_or(f64::NAN)
422 4 : .total_cmp(&y.as_f64().unwrap_or(f64::NAN)),
423 0 : (Value::Bool(bx), Value::Bool(by)) => bx.cmp(by),
424 64 : (Value::String(sx), Value::String(sy)) => {
425 64 : if rx == 7 {
426 : // DateTime: canonical key so equal instants in
427 : // different 4.6.3 fraction spellings tie (4.11)
428 0 : antares_model::dt_key(sx).cmp(&antares_model::dt_key(sy))
429 64 : } else if let Some(c) = coll {
430 : // 4.23.3 EXAMPLES 6/7: the named ICU collation
431 26 : c.compare(sx, sy)
432 : } else {
433 : // 4.23.1 default: codepoint order
434 38 : sx.cmp(sy)
435 : }
436 : }
437 0 : _ => x.to_string().cmp(&y.to_string()),
438 : }
439 : }
440 : }
441 216 : }
442 262 : docs.sort_by(|a, b| {
443 : use std::cmp::Ordering;
444 266 : for k in &keys {
445 266 : let o = match k.dir {
446 : Dir::Asc | Dir::Desc => {
447 212 : let va = order_value(a, k, ctx);
448 212 : let vb = order_value(b, k, ctx);
449 212 : let mut o = cmp_vals(&va, &vb, collator.as_ref());
450 212 : if k.dir == Dir::Desc {
451 4 : o = o.reverse();
452 208 : }
453 212 : o
454 : }
455 : Dir::DistAsc | Dir::DistDesc => {
456 : // Set whenever a Dist ordering was accepted. Without it
457 : // there is no distance to compare, and every pair being
458 : // equal leaves the previous order untouched.
459 54 : let Some(refg) = refg.as_ref() else {
460 0 : return std::cmp::Ordering::Equal;
461 : };
462 54 : let da = order_value(a, k, ctx)
463 54 : .and_then(|v| antares_ql::geo::order_distance_m(refg, &v));
464 54 : let db = order_value(b, k, ctx)
465 54 : .and_then(|v| antares_ql::geo::order_distance_m(refg, &v));
466 54 : match (da, db) {
467 42 : (Some(x), Some(y)) => {
468 42 : let mut o = x.total_cmp(&y);
469 42 : if k.dir == Dir::DistDesc {
470 14 : o = o.reverse();
471 28 : }
472 42 : o
473 : }
474 : // 4.23.2 distance order: GeoProperties (by distance)
475 : // rank before non-GeoProperties (by value)
476 8 : (Some(_), None) => Ordering::Less,
477 0 : (None, Some(_)) => Ordering::Greater,
478 : (None, None) => {
479 4 : let va = order_value(a, k, ctx);
480 4 : let vb = order_value(b, k, ctx);
481 4 : cmp_vals(&va, &vb, collator.as_ref())
482 : }
483 : }
484 : }
485 : };
486 266 : if o != Ordering::Equal {
487 262 : return o;
488 4 : }
489 : }
490 0 : Ordering::Equal
491 262 : });
492 90 : Ok(())
493 108 : }
494 :
495 : /// 5.2.23 Query: flatten the JSON members into query-param form with the
496 : /// Table 5.2.23-1 value spaces enforced — entities is a non-empty
497 : /// EntitySelector[], string members must be strings, string-array members
498 : /// are non-empty arrays of strings, joinLevel is a positive integer,
499 : /// entityMap/splitEntities are booleans, geoQ/ordering are objects.
500 : /// `temporal` selects the "Query Temporal Evolution of Entities" reading:
501 : /// temporalQ/aggrParams are only allowed there, containedBy only outside it.
502 586 : pub(crate) fn query_doc_params(
503 586 : q: &Map<String, Value>,
504 586 : temporal: bool,
505 586 : vp: &mut HashMap<String, String>,
506 586 : ) -> Result<(), NgsiError> {
507 586 : let bad = NgsiError::BadRequestData;
508 : // A member lifted out of the body becomes the same parameter the GET twin
509 : // carries in the URI, where it is capped at MAX_URI_BYTES (6.3.4 bare
510 : // 414). Without the same cap here the POST form is the cheap way to hand
511 : // the query and projection parsers a multi-megabyte string. That includes
512 : // the three parameters assembled from the `entities` selectors below:
513 : // there is no cap on the selector array, so a body inside MAX_BODY_BYTES
514 : // holds hundreds of thousands of them, each costing one expanded IRI and
515 : // one store bind.
516 758 : let capped = |k: &str, s: String| -> Result<String, NgsiError> {
517 758 : if s.len() > crate::bounds::MAX_URI_BYTES {
518 24 : return Err(bad(format!(
519 24 : "Query {k} exceeds the {} byte limit",
520 24 : crate::bounds::MAX_URI_BYTES
521 24 : )));
522 734 : }
523 734 : Ok(s)
524 758 : };
525 586 : match q.get("entities") {
526 36 : None => {}
527 546 : Some(Value::Array(es)) if !es.is_empty() => {
528 538 : let (mut types, mut ids, mut pats) = (Vec::new(), Vec::new(), Vec::new());
529 538 : let (mut with_id, mut with_pat) = (0usize, 0usize);
530 2950 : for e in es {
531 2950 : if !e.is_object() {
532 4 : return Err(bad(
533 4 : "entities entries must be EntitySelector objects (5.2.33)".into(),
534 4 : ));
535 2946 : }
536 : // Table 5.2.33-1: type is the mandatory selector member (a
537 : // 4.17 type selection, "*" allowed)
538 2946 : match e.get("type") {
539 2922 : Some(Value::String(s)) if !s.is_empty() => types.push(s.clone()),
540 24 : _ => return Err(bad("EntitySelector requires type (5.2.33)".into())),
541 : }
542 : // id: "String or String[]", valid URI(s)
543 2922 : match e.get("id") {
544 2860 : None => {}
545 42 : Some(Value::String(s)) => {
546 42 : antares_model::EntityId::new(s)?;
547 32 : ids.push(s.clone());
548 32 : with_id += 1;
549 : }
550 20 : Some(Value::Array(a)) => {
551 36 : for i in a {
552 36 : let s = i.as_str().ok_or_else(|| {
553 8 : bad("EntitySelector id entries must be URIs (5.2.33)".into())
554 8 : })?;
555 28 : antares_model::EntityId::new(s)?;
556 28 : ids.push(s.to_owned());
557 : }
558 12 : with_id += 1;
559 : }
560 : Some(_) => {
561 0 : return Err(bad(
562 0 : "EntitySelector id must be a URI string or array (5.2.33)".into(),
563 0 : ))
564 : }
565 : }
566 2904 : match e.get("idPattern") {
567 2874 : None => {}
568 30 : Some(Value::String(s)) => {
569 30 : pats.push(s.clone());
570 30 : with_pat += 1;
571 30 : }
572 : Some(_) => {
573 0 : return Err(bad(
574 0 : "EntitySelector idPattern must be a string (5.2.33)".into()
575 0 : ))
576 : }
577 : }
578 : }
579 492 : if !types.is_empty() {
580 492 : vp.insert("type".into(), capped("entities type", types.join(","))?);
581 0 : }
582 : // 5.2.33: the selectors are a union, and "id takes precedence over
583 : // idPattern" holds PER selector. These flat params carry a single
584 : // id/idPattern pair applied to the whole result, so a member is
585 : // only emitted when every selector agrees on it — otherwise one
586 : // selector's id would filter away the Entities another selector
587 : // selects on its own. Where they disagree the type predicate alone
588 : // stands, which over-matches rather than losing Entities.
589 488 : if with_id == es.len() && !ids.is_empty() {
590 32 : vp.insert("id".into(), capped("entities id", ids.join(","))?);
591 456 : }
592 488 : if with_id == 0 && with_pat == es.len() && !pats.is_empty() {
593 10 : vp.insert(
594 10 : "idPattern".into(),
595 10 : capped("entities idPattern", pats.join("|"))?,
596 : );
597 478 : }
598 : }
599 : Some(_) => {
600 12 : return Err(bad(
601 12 : "entities must be a non-empty EntitySelector array (5.2.23)".into(),
602 12 : ))
603 : }
604 : }
605 3580 : for k in [
606 524 : "q",
607 524 : "scopeQ",
608 524 : "csf",
609 524 : "lang",
610 524 : "join",
611 524 : "expandValues",
612 524 : "jsonKeys",
613 524 : ] {
614 3580 : match q.get(k) {
615 3512 : None => {}
616 56 : Some(Value::String(s)) => {
617 56 : vp.insert(k.into(), capped(k, s.clone())?);
618 : }
619 12 : Some(_) => return Err(bad(format!("Query {k} must be a string (5.2.23)"))),
620 : }
621 : }
622 : // string-array members; "Empty array (0 length) is not allowed"
623 2452 : for k in ["attrs", "pick", "omit", "containedBy", "datasetId"] {
624 2452 : match q.get(k) {
625 2392 : None => {}
626 56 : Some(Value::Array(a)) if !a.is_empty() => {
627 40 : if k == "containedBy" && temporal {
628 4 : return Err(bad(
629 4 : "containedBy is only applicable to Retrieve Entity and Query Entities (5.2.23)"
630 4 : .into(),
631 4 : ));
632 36 : }
633 36 : let mut parts = Vec::with_capacity(a.len());
634 2432 : for m in a {
635 2432 : parts.push(m.as_str().ok_or_else(|| {
636 4 : bad(format!("Query {k} entries must be strings (5.2.23)"))
637 4 : })?);
638 : }
639 32 : vp.insert(k.into(), capped(k, parts.join(","))?);
640 : }
641 : Some(_) => {
642 20 : return Err(bad(format!(
643 20 : "Query {k} must be a non-empty array of strings (5.2.23)"
644 20 : )))
645 : }
646 : }
647 : }
648 476 : if let Some(n) = q.get("joinLevel") {
649 14 : let v = n
650 14 : .as_u64()
651 14 : .filter(|v| *v >= 1)
652 14 : .ok_or_else(|| bad("Query joinLevel must be a positive integer (5.2.23)".into()))?;
653 2 : vp.insert("joinLevel".into(), v.to_string());
654 462 : }
655 924 : for k in ["entityMap", "splitEntities"] {
656 924 : match q.get(k) {
657 904 : None => {}
658 12 : Some(Value::Bool(b)) => {
659 12 : vp.insert(k.into(), b.to_string());
660 12 : }
661 8 : Some(_) => return Err(bad(format!("Query {k} must be a boolean (5.2.23)"))),
662 : }
663 : }
664 456 : if let Some(l) = q.get("entityMapLifetime") {
665 : // ISO 8601 duration; EntityMap lifetimes are the broker's call
666 : // ("possibly overriding the requested duration") — 5.14.x surface.
667 0 : if !l.is_string() {
668 0 : return Err(bad(
669 0 : "Query entityMapLifetime must be a string (5.2.23)".into()
670 0 : ));
671 0 : }
672 456 : }
673 456 : match q.get("geoQ") {
674 438 : None => {}
675 14 : Some(Value::Object(g)) => {
676 34 : for k in ["georel", "geometry", "geoproperty"] {
677 34 : match g.get(k) {
678 10 : None => {}
679 24 : Some(Value::String(s)) => {
680 24 : vp.insert(k.into(), capped(k, s.clone())?);
681 : }
682 0 : Some(_) => return Err(bad(format!("geoQ {k} must be a string (5.2.13)"))),
683 : }
684 : }
685 : // `coordinates` is the one lifted member NOT capped in bytes: its
686 : // ceiling is MAX_GEO_VERTICES (1024), which a legal polygon can
687 : // spend more than MAX_URI_BYTES on. One cap per parameter, the
688 : // one that governs it.
689 10 : if let Some(c) = g.get("coordinates") {
690 10 : vp.insert(
691 10 : "coordinates".into(),
692 10 : match c {
693 6 : Value::String(s) => s.clone(),
694 4 : other => other.to_string(),
695 : },
696 : );
697 0 : }
698 : }
699 4 : Some(_) => return Err(bad("geoQ must be a GeoQuery object (5.2.13)".into())),
700 : }
701 448 : match q.get("temporalQ") {
702 336 : None => {}
703 112 : Some(Value::Object(tq)) if temporal => temporal_q_params(tq, vp)?,
704 : Some(Value::Object(_)) => {
705 8 : return Err(bad(
706 8 : "temporalQ is only allowed for Query Temporal Evolution of Entities (5.2.23)"
707 8 : .into(),
708 8 : ))
709 : }
710 : Some(_) => {
711 0 : return Err(bad(
712 0 : "temporalQ must be a TemporalQuery object (5.2.21)".into()
713 0 : ))
714 : }
715 : }
716 416 : match q.get("aggrParams") {
717 384 : None => {}
718 28 : Some(Value::Object(ap)) if temporal => {
719 : // 5.2.44 AggregationParams: aggrMethods + aggrPeriodDuration
720 24 : match ap.get("aggrMethods") {
721 0 : None => {}
722 4 : Some(Value::String(s)) => {
723 4 : vp.insert("aggrMethods".into(), capped("aggrMethods", s.clone())?);
724 : }
725 16 : Some(Value::Array(a)) => {
726 16 : let mut parts = Vec::with_capacity(a.len());
727 2412 : for m in a {
728 2412 : parts.push(m.as_str().ok_or_else(|| {
729 0 : bad("aggrParams aggrMethods entries must be strings (5.2.44)".into())
730 0 : })?);
731 : }
732 16 : vp.insert(
733 16 : "aggrMethods".into(),
734 16 : capped("aggrMethods", parts.join(","))?,
735 : );
736 : }
737 : Some(_) => {
738 4 : return Err(bad(
739 4 : "aggrParams aggrMethods must be a comma separated list of strings (5.2.44)"
740 4 : .into(),
741 4 : ))
742 : }
743 : }
744 16 : match ap.get("aggrPeriodDuration") {
745 8 : None => {}
746 8 : Some(Value::String(s)) => {
747 8 : vp.insert(
748 8 : "aggrPeriodDuration".into(),
749 8 : capped("aggrPeriodDuration", s.clone())?,
750 : );
751 : }
752 : Some(_) => {
753 0 : return Err(bad(
754 0 : "aggrParams aggrPeriodDuration must be a string (5.2.44)".into(),
755 0 : ))
756 : }
757 : }
758 : }
759 : Some(Value::Object(_)) => {
760 4 : return Err(bad(
761 4 : "aggrParams is only allowed for Query Temporal Evolution of Entities (5.2.23)"
762 4 : .into(),
763 4 : ))
764 : }
765 : Some(_) => {
766 4 : return Err(bad(
767 4 : "aggrParams must be an AggregationParams object (5.2.44)".into(),
768 4 : ))
769 : }
770 : }
771 400 : match q.get("ordering") {
772 338 : None => {}
773 58 : Some(Value::Object(o)) => {
774 : // Table 5.2.43-1 (OrderingParams): orderBy String[] -> the 4.23
775 : // keys; coordinates (JSON array) + geometry (default "Point")
776 : // -> the dist-ordering reference (orderFrom/orderGeometry).
777 : // Every member lifted here takes the same MAX_URI_BYTES cap as
778 : // the rest of the body, for the same reason: it becomes the
779 : // parameter the GET twin carries in its URI. `coordinates` is
780 : // again the exception — its ceiling is MAX_GEO_VERTICES, the one
781 : // that governs a reference geometry.
782 58 : if let Some(ob) = o.get("orderBy") {
783 58 : let a = ob.as_array().ok_or_else(|| {
784 0 : bad("ordering orderBy must be an array of strings (5.2.43)".into())
785 0 : })?;
786 58 : let mut parts = Vec::with_capacity(a.len());
787 2458 : for m in a {
788 2458 : parts.push(m.as_str().ok_or_else(|| {
789 0 : bad("ordering orderBy entries must be strings (5.2.43)".into())
790 0 : })?);
791 : }
792 58 : vp.insert("orderBy".into(), capped("orderBy", parts.join(","))?);
793 0 : }
794 54 : match o.get("coordinates") {
795 38 : None => {}
796 8 : Some(Value::Array(c)) => {
797 8 : vp.insert("orderFrom".into(), Value::Array(c.clone()).to_string());
798 8 : }
799 : Some(_) => {
800 8 : return Err(bad(
801 8 : "ordering coordinates must be a JSON array (5.2.43)".into()
802 8 : ))
803 : }
804 : }
805 46 : match o.get("geometry") {
806 30 : None => {}
807 8 : Some(Value::String(g)) => {
808 8 : vp.insert("orderGeometry".into(), capped("orderGeometry", g.clone())?);
809 : }
810 8 : Some(_) => return Err(bad("ordering geometry must be a string (5.2.43)".into())),
811 : }
812 38 : match o.get("collation") {
813 16 : None => {}
814 18 : Some(Value::String(c)) => {
815 18 : vp.insert("collation".into(), capped("collation", c.clone())?);
816 : }
817 4 : Some(_) => return Err(bad("ordering collation must be a string (5.2.43)".into())),
818 : }
819 : }
820 : Some(_) => {
821 4 : return Err(bad(
822 4 : "ordering must be an OrderingParams object (5.2.43)".into()
823 4 : ))
824 : }
825 : }
826 372 : Ok(())
827 586 : }
828 :
829 : /// Percent-encode one client-controlled value for use as a query-string
830 : /// value (RFC 3986 clause 3.4: a query is made of `pchar`, `/` and `?`).
831 : /// Parameters reach a handler already percent-decoded, so a value spliced
832 : /// back into a URI raw would change the query it belongs to (`&` and `=`
833 : /// start another parameter, `%` re-decodes, `+` reads back as a space) and,
834 : /// in a Link header, `>` would end the link-value (RFC 8288 clause 3).
835 380 : pub(crate) fn query_value(s: &str) -> String {
836 380 : pct_encode(s, |b| {
837 90 : matches!(
838 282 : b,
839 : b'-' | b'.'
840 : | b'_'
841 : | b'~'
842 : | b'!'
843 : | b'$'
844 : | b'\''
845 : | b'('
846 : | b')'
847 : | b'*'
848 : | b','
849 : | b';'
850 : | b':'
851 : | b'@'
852 : | b'/'
853 : | b'?'
854 : )
855 282 : })
856 380 : }
857 :
858 : /// RFC 3986 clause 2.1: every byte the caller does not keep becomes its
859 : /// percent-encoded triplet; ASCII letters and digits are always kept.
860 8632 : pub(crate) fn pct_encode(s: &str, keep: impl Fn(u8) -> bool) -> String {
861 8632 : let mut out = String::with_capacity(s.len());
862 214796 : for b in s.bytes() {
863 214796 : if b.is_ascii_alphanumeric() || keep(b) {
864 214558 : out.push(b as char);
865 214558 : } else {
866 238 : out.push_str(&format!("%{b:02X}"));
867 238 : }
868 : }
869 8632 : out
870 8632 : }
871 :
872 : /// 5.2.21 TemporalQuery (JSON form): flatten the object's members into
873 : /// query-param form, enforcing the Table 5.2.21-1 value spaces — the string
874 : /// members must be JSON strings, aggrMethods a comma separated list of
875 : /// string (string or string-array spelling), lastN a positive integer.
876 : /// Vocabulary/range rules are then enforced by the shared param validators
877 : /// (TemporalQ::from_params, parse_trepr).
878 136 : pub(crate) fn temporal_q_params(
879 136 : tq: &Map<String, Value>,
880 136 : out: &mut HashMap<String, String>,
881 136 : ) -> Result<(), NgsiError> {
882 136 : let bad = NgsiError::BadRequestData;
883 668 : for k in [
884 136 : "timerel",
885 136 : "timeAt",
886 136 : "endTimeAt",
887 136 : "timeproperty",
888 136 : "aggrPeriodDuration",
889 136 : ] {
890 668 : match tq.get(k) {
891 388 : None => {}
892 276 : Some(Value::String(s)) => {
893 276 : out.insert(k.into(), s.clone());
894 276 : }
895 4 : Some(_) => return Err(bad(format!("temporalQ {k} must be a string (5.2.21)"))),
896 : }
897 : }
898 132 : if let Some(n) = tq.get("lastN") {
899 20 : let v = n
900 20 : .as_u64()
901 20 : .filter(|v| *v >= 1)
902 20 : .ok_or_else(|| bad("temporalQ lastN must be a positive integer (5.2.21)".into()))?;
903 4 : out.insert("lastN".into(), v.to_string());
904 112 : }
905 116 : match tq.get("aggrMethods") {
906 96 : None => {}
907 4 : Some(Value::String(s)) => {
908 4 : out.insert("aggrMethods".into(), s.clone());
909 4 : }
910 12 : Some(Value::Array(a)) => {
911 12 : let mut parts = Vec::with_capacity(a.len());
912 16 : for m in a {
913 16 : parts.push(m.as_str().ok_or_else(|| {
914 0 : bad("temporalQ aggrMethods entries must be strings (5.2.21)".into())
915 0 : })?);
916 : }
917 12 : out.insert("aggrMethods".into(), parts.join(","));
918 : }
919 : Some(_) => {
920 4 : return Err(bad(
921 4 : "temporalQ aggrMethods must be a comma separated list of string (5.2.21)".into(),
922 4 : ))
923 : }
924 : }
925 112 : Ok(())
926 136 : }
927 :
928 : #[cfg(test)]
929 : mod tests {
930 : use super::*;
931 : use antares_jsonld::Loader;
932 : use serde_json::json;
933 :
934 : const D: &str = "https://uri.etsi.org/ngsi-ld/default-context/";
935 :
936 12 : fn state() -> AppState {
937 12 : AppState::new("http://localhost:9090".into())
938 12 : }
939 :
940 152 : fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
941 152 : pairs
942 152 : .iter()
943 164 : .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
944 152 : .collect()
945 152 : }
946 :
947 20 : fn items(n: usize) -> Vec<Value> {
948 4060 : (0..n).map(|i| json!({"id": format!("urn:{i}")})).collect()
949 20 : }
950 :
951 44 : fn ent(id: &str, attr: &str, v: Value) -> Value {
952 44 : json!({"id": id, "type": ["T"],
953 44 : format!("{D}{attr}"): [{"type": "Property", "value": v}]})
954 44 : }
955 :
956 24 : fn ids(docs: &[Value]) -> Vec<&str> {
957 96 : docs.iter().map(|d| d["id"].as_str().unwrap()).collect()
958 24 : }
959 :
960 : /// 4.12: clients specify a limit (page size); a next link flags remaining
961 : /// elements; prev enables backwards iteration; absent on the edges.
962 : #[test]
963 4 : fn next_and_prev_flag_remaining_elements() {
964 4 : let st = state();
965 4 : let (page, _, links) = paginate(&st, ¶ms(&[("limit", "1")]), items(3), "/e").unwrap();
966 4 : assert_eq!(page.len(), 1);
967 4 : assert!(links.iter().any(|l| l.contains("rel=\"next\"")));
968 4 : assert!(
969 4 : !links.iter().any(|l| l.contains("rel=\"prev\"")),
970 : "no prev on the first page"
971 : );
972 4 : let (_, _, links) = paginate(
973 4 : &st,
974 4 : ¶ms(&[("limit", "1"), ("offset", "1")]),
975 4 : items(3),
976 4 : "/e",
977 4 : )
978 4 : .unwrap();
979 4 : assert!(links.iter().any(|l| l.contains("rel=\"next\"")));
980 8 : assert!(links.iter().any(|l| l.contains("rel=\"prev\"")));
981 4 : let (_, _, links) = paginate(
982 4 : &st,
983 4 : ¶ms(&[("limit", "1"), ("offset", "2")]),
984 4 : items(3),
985 4 : "/e",
986 4 : )
987 4 : .unwrap();
988 4 : assert!(
989 4 : !links.iter().any(|l| l.contains("rel=\"next\"")),
990 : "no next on the last page"
991 : );
992 4 : assert!(links.iter().any(|l| l.contains("rel=\"prev\"")));
993 4 : }
994 :
995 : /// 4.12: "define a default limit (default page size)" — applied when the
996 : /// client sends none.
997 : #[test]
998 4 : fn default_page_size_applies() {
999 4 : let st = state();
1000 4 : let n = st.default_limit + 5;
1001 4 : let (page, _, links) = paginate(&st, ¶ms(&[]), items(n), "/e").unwrap();
1002 4 : assert_eq!(page.len(), st.default_limit);
1003 4 : assert!(links.iter().any(|l| l.contains("rel=\"next\"")));
1004 4 : }
1005 :
1006 : /// 4.12 should: a hard result-size ceiling, rejected with TooManyResults
1007 : /// (not silently clamped).
1008 : #[test]
1009 4 : fn limit_above_the_ceiling_is_too_many_results() {
1010 4 : let st = state();
1011 4 : let over = (st.max_limit + 1).to_string();
1012 4 : let err = paginate(&st, ¶ms(&[("limit", &over)]), items(1), "/e").unwrap_err();
1013 4 : assert!(format!("{err:?}").contains("TooManyResults"));
1014 4 : }
1015 :
1016 : /// 4.13: the result count is relayed "whenever this is requested by the
1017 : /// client" — and only then.
1018 : #[test]
1019 4 : fn count_is_returned_only_on_request() {
1020 4 : let st = AppState::new("http://localhost:9090".into());
1021 12 : let items: Vec<Value> = (0..3).map(|i| json!({"id": format!("urn:{i}")})).collect();
1022 4 : let (_, count, _) =
1023 4 : paginate(&st, ¶ms(&[("count", "true")]), items.clone(), "/e").unwrap();
1024 4 : assert_eq!(count, Some(3));
1025 4 : let (_, count, _) = paginate(&st, ¶ms(&[]), items, "/e").unwrap();
1026 4 : assert_eq!(count, None, "no count member unless requested");
1027 4 : }
1028 :
1029 : /// 4.13: "a client can issue a query that limits to zero the number of
1030 : /// desired results but asks for the count to be present" — limit=0 is
1031 : /// only valid together with count.
1032 : #[test]
1033 4 : fn limit_zero_with_count_yields_an_empty_page_and_the_total() {
1034 4 : let st = AppState::new("http://localhost:9090".into());
1035 28 : let items: Vec<Value> = (0..7).map(|i| json!({"id": format!("urn:{i}")})).collect();
1036 4 : let (page, count, links) = paginate(
1037 4 : &st,
1038 4 : ¶ms(&[("limit", "0"), ("count", "true")]),
1039 4 : items.clone(),
1040 4 : "/e",
1041 4 : )
1042 4 : .unwrap();
1043 4 : assert!(page.is_empty());
1044 4 : assert_eq!(count, Some(7));
1045 4 : assert!(links.is_empty(), "limit=0 pages have no next/prev");
1046 4 : assert!(
1047 4 : paginate(&st, ¶ms(&[("limit", "0")]), items, "/e").is_err(),
1048 : "limit=0 without count is rejected"
1049 : );
1050 4 : }
1051 :
1052 : /// 4.23.2: mixed datatypes order as Numbers < Strings < Object < Array <
1053 : /// Boolean < Time < Date < DateTime < Null < absent.
1054 : #[test]
1055 4 : fn datatype_comparison_order() {
1056 4 : let ctx = Loader::new().core();
1057 4 : let mut docs = vec![
1058 4 : ent("urn:null", "x", Value::Null),
1059 4 : ent("urn:datetime", "x", json!("2020-01-01T00:00:00Z")),
1060 4 : ent("urn:bool", "x", json!(true)),
1061 4 : json!({"id": "urn:absent", "type": ["T"]}),
1062 4 : ent("urn:array", "x", json!([1, 2])),
1063 4 : ent("urn:string", "x", json!("abc")),
1064 4 : ent("urn:date", "x", json!("2020-01-01")),
1065 4 : ent("urn:object", "x", json!({"k": 1})),
1066 4 : ent("urn:number", "x", json!(5)),
1067 4 : ent("urn:time", "x", json!("12:00:00Z")),
1068 : ];
1069 4 : order_entities(&mut docs, "x", ¶ms(&[]), &ctx).expect("order");
1070 4 : assert_eq!(
1071 4 : ids(&docs),
1072 4 : vec![
1073 : "urn:number",
1074 4 : "urn:string",
1075 4 : "urn:object",
1076 4 : "urn:array",
1077 4 : "urn:bool",
1078 4 : "urn:time",
1079 4 : "urn:date",
1080 4 : "urn:datetime",
1081 4 : "urn:null",
1082 4 : "urn:absent"
1083 : ]
1084 : );
1085 4 : }
1086 :
1087 : /// 4.23.3 EXAMPLES 8/9: dist-asc / dist-desc rank by haversine distance
1088 : /// from the orderFrom reference; a non-GeoProperty under a dist ordering
1089 : /// falls back to value ordering after the geo-ranked ones (4.23.2).
1090 : #[test]
1091 4 : fn distance_ordering() {
1092 4 : let ctx = Loader::new().core();
1093 16 : let geo = |id: &str, lon: f64, lat: f64| {
1094 16 : json!({"id": id, "type": ["T"],
1095 16 : "https://uri.etsi.org/ngsi-ld/location": [
1096 16 : {"type": "GeoProperty",
1097 16 : "value": {"type": "Point", "coordinates": [lon, lat]}}]})
1098 16 : };
1099 4 : let mut docs = vec![
1100 4 : geo("urn:far", 10.0, 45.0),
1101 4 : geo("urn:near", 8.01, 40.01),
1102 4 : geo("urn:mid", 9.0, 41.0),
1103 : ];
1104 4 : let p = params(&[("orderFrom", "[8,40]")]);
1105 4 : order_entities(&mut docs, "location;dist-asc", &p, &ctx).expect("order");
1106 4 : assert_eq!(ids(&docs), vec!["urn:near", "urn:mid", "urn:far"]);
1107 4 : order_entities(&mut docs, "location;dist-desc", &p, &ctx).expect("order");
1108 4 : assert_eq!(ids(&docs), vec!["urn:far", "urn:mid", "urn:near"]);
1109 : // dist without orderFrom is a violation
1110 4 : assert!(order_entities(&mut docs, "location;dist-asc", ¶ms(&[]), &ctx).is_err());
1111 : // 4.23.2: under a distance ordering the GeoProperties rank first by
1112 : // distance, and the non-GeoProperties after them BY VALUE — so the
1113 : // ordering member has to be the same (core) one the geo entities use,
1114 : // and there have to be two of them for their own order to mean
1115 : // anything.
1116 8 : let plain = |id: &str, v: &str| {
1117 8 : json!({"id": id, "type": ["T"],
1118 8 : "https://uri.etsi.org/ngsi-ld/location": [
1119 8 : {"type": "Property", "value": v}]})
1120 8 : };
1121 4 : let mut mixed = vec![
1122 4 : plain("urn:plain-z", "zzz"),
1123 4 : plain("urn:plain-a", "aaa"),
1124 4 : geo("urn:g", 8.0, 40.0),
1125 : ];
1126 4 : order_entities(&mut mixed, "location;dist-asc", &p, &ctx).expect("order");
1127 4 : assert_eq!(ids(&mixed), vec!["urn:g", "urn:plain-a", "urn:plain-z"]);
1128 4 : }
1129 :
1130 : /// 4.23.3 EXAMPLE 4: a trailing [path] addresses a compound-value
1131 : /// subitem; EXAMPLE 3: per-key directions apply sequentially.
1132 : #[test]
1133 4 : fn bracket_paths_and_sequential_keys() {
1134 4 : let ctx = Loader::new().core();
1135 8 : let addr = |id: &str, city: &str| ent(id, "address", json!({"city": city}));
1136 4 : let mut docs = vec![addr("urn:b", "Berlin"), addr("urn:a", "Amsterdam")];
1137 4 : order_entities(&mut docs, "address[city]", ¶ms(&[]), &ctx).expect("order");
1138 4 : assert_eq!(ids(&docs), vec!["urn:a", "urn:b"]);
1139 : // name asc, then age desc among equals (EXAMPLE 3)
1140 12 : let two = |id: &str, name: &str, age: i64| {
1141 12 : json!({"id": id, "type": ["T"],
1142 12 : format!("{D}name"): [{"type": "Property", "value": name}],
1143 12 : format!("{D}age"): [{"type": "Property", "value": age}]})
1144 12 : };
1145 4 : let mut docs = vec![
1146 4 : two("urn:x1", "same", 1),
1147 4 : two("urn:x9", "same", 9),
1148 4 : two("urn:a", "aaa", 5),
1149 : ];
1150 4 : order_entities(&mut docs, "name,age;desc", ¶ms(&[]), &ctx).expect("order");
1151 4 : assert_eq!(ids(&docs), vec!["urn:a", "urn:x9", "urn:x1"]);
1152 4 : }
1153 :
1154 : /// 6.3.10 count-only page (`limit=0&count=true`): the pushed shape — no
1155 : /// rows from the store plus its pre-LIMIT count — must be the same answer
1156 : /// the full scan builds from a materialized match set, page contents,
1157 : /// count and Links included.
1158 : #[test]
1159 4 : fn the_count_only_page_is_the_same_answer_pushed_or_scanned() {
1160 4 : let st = AppState::new("antares-test".into());
1161 4 : let matches: Vec<Value> = (0..7)
1162 28 : .map(|i| serde_json::json!({"id": format!("urn:ngsi-ld:T:{i}"), "type": "T"}))
1163 4 : .collect();
1164 8 : for extra in [vec![], vec![("offset", "3")]] {
1165 8 : let mut pairs = vec![("type", "T"), ("limit", "0"), ("count", "true")];
1166 8 : pairs.extend(extra.iter().copied());
1167 8 : let p = params(&pairs);
1168 8 : let scanned = paginate(&st, &p, matches.clone(), "/ngsi-ld/v1/entities").expect("scan");
1169 8 : let pushed = paginate_pre(
1170 8 : &st,
1171 8 : &p,
1172 8 : Vec::new(),
1173 8 : "/ngsi-ld/v1/entities",
1174 : // what the store's count(*) reports for the same query
1175 8 : matches.len(),
1176 : )
1177 8 : .expect("pushed");
1178 8 : assert_eq!(scanned.0, pushed.0, "page contents differ: {pairs:?}");
1179 8 : assert_eq!(scanned.1, pushed.1, "count differs: {pairs:?}");
1180 8 : assert_eq!(scanned.2, pushed.2, "Links differ: {pairs:?}");
1181 8 : assert!(
1182 8 : pushed.0.is_empty(),
1183 : "a count-only page carries no Entity: {:?}",
1184 : pushed.0
1185 : );
1186 8 : assert_eq!(pushed.1, Some(matches.len()), "the count is the match set");
1187 8 : assert!(
1188 8 : !pushed.2.iter().any(|l| l.contains("rel=\"next\"")),
1189 : "a page of zero has no next page: {:?}",
1190 : pushed.2
1191 : );
1192 : }
1193 4 : }
1194 :
1195 : /// 5.5.9 pagination is driven by `limit` and `offset`, and 5.5.6 answers
1196 : /// a request for "so many results that can potentially exhaust client or
1197 : /// server resources" with TooManyResults rather than clamping. 6.3.10
1198 : /// takes `limit=0` only together with `count=true`. Every one of these is
1199 : /// a client-supplied number, so the boundaries are what a client reaches
1200 : /// for: the largest offset the store can bind, the first one it cannot,
1201 : /// a number no integer type holds, and the negative form of both.
1202 : #[test]
1203 4 : fn the_paging_numbers_are_refused_at_their_boundaries_not_wrapped() {
1204 4 : let st = AppState::new("http://localhost:9090".into());
1205 :
1206 4 : let (offset, _, _) = page_params(&st, ¶ms(&[("offset", &i64::MAX.to_string())]))
1207 4 : .expect("the largest offset the store can bind is servable");
1208 4 : assert_eq!(offset, i64::MAX as usize);
1209 :
1210 : // one past it wraps negative when bound as `$n::bigint`, and a
1211 : // negative OFFSET is a Postgres error, so it is a bad request here
1212 4 : let over = (i64::MAX as u128 + 1).to_string();
1213 4 : assert!(
1214 4 : matches!(
1215 4 : page_params(&st, ¶ms(&[("offset", &over)])),
1216 4 : Err(e) if matches!(e, crate::negotiate::ApiError::Ngsi(NgsiError::BadRequestData(_)))
1217 : ),
1218 : "an offset above i64::MAX must not reach the store"
1219 : );
1220 :
1221 24 : for bad in ["-1", "1.5", "", " 1", "0x10", "99999999999999999999999999"] {
1222 24 : assert!(
1223 24 : matches!(
1224 24 : page_params(&st, ¶ms(&[("offset", bad)])),
1225 24 : Err(e) if matches!(e, crate::negotiate::ApiError::Ngsi(NgsiError::BadRequestData(_)))
1226 : ),
1227 : "offset {bad:?} is not a number this API takes"
1228 : );
1229 24 : assert!(
1230 24 : matches!(
1231 24 : page_params(&st, ¶ms(&[("limit", bad)])),
1232 24 : Err(e) if matches!(e, crate::negotiate::ApiError::Ngsi(NgsiError::BadRequestData(_)))
1233 : ),
1234 : "limit {bad:?} is not a number this API takes"
1235 : );
1236 : }
1237 :
1238 : // 5.5.6: over the server maximum is 403, never a silent clamp
1239 4 : let over_max = (st.max_limit + 1).to_string();
1240 4 : assert!(
1241 4 : matches!(
1242 4 : page_params(&st, ¶ms(&[("limit", &over_max)])),
1243 4 : Err(e) if matches!(e, crate::negotiate::ApiError::Ngsi(NgsiError::TooManyResults(_)))
1244 : ),
1245 : "a limit above the maximum is TooManyResults"
1246 : );
1247 4 : let (_, limit, _) = page_params(&st, ¶ms(&[("limit", &st.max_limit.to_string())]))
1248 4 : .expect("the maximum itself is servable");
1249 4 : assert_eq!(limit, st.max_limit, "the boundary value is not clamped");
1250 :
1251 : // 6.3.10: limit=0 is the count-only request and nothing else
1252 4 : assert!(
1253 4 : matches!(
1254 4 : page_params(&st, ¶ms(&[("limit", "0")])),
1255 4 : Err(e) if matches!(e, crate::negotiate::ApiError::Ngsi(NgsiError::BadRequestData(_)))
1256 : ),
1257 : "limit=0 without count asks for a page of nothing"
1258 : );
1259 4 : let (_, limit, count) = page_params(&st, ¶ms(&[("limit", "0"), ("count", "true")]))
1260 4 : .expect("limit=0 with count=true is the count-only request");
1261 4 : assert_eq!((limit, count), (0, true));
1262 :
1263 : // `count` is the literal "true" and nothing else is a count request
1264 16 : for not_true in ["TRUE", "1", "yes", ""] {
1265 16 : let (_, _, count) = page_params(&st, ¶ms(&[("count", not_true)])).expect("params");
1266 16 : assert!(!count, "count={not_true:?} is not count=true");
1267 : }
1268 4 : }
1269 :
1270 : /// Table 5.2.23-1 splitEntities: "If true it is assumed that single
1271 : /// Entities are distributed between different Context Brokers and/or
1272 : /// Context Sources and this has to be taken into account when applying
1273 : /// any kind of filters" — the body member drives the same read as the
1274 : /// query-parameter twin, so it has to reach the filter path.
1275 : #[test]
1276 4 : fn query_body_split_entities_reaches_the_filter_params() {
1277 4 : let q = json!({"type": "Query", "entityMap": true, "splitEntities": true});
1278 4 : let mut vp = HashMap::new();
1279 4 : query_doc_params(q.as_object().expect("object"), false, &mut vp).expect("valid Query");
1280 4 : assert_eq!(
1281 4 : vp.get("splitEntities").map(String::as_str),
1282 : Some("true"),
1283 : "splitEntities must not be dropped: {vp:?}"
1284 : );
1285 4 : assert_eq!(vp.get("entityMap").map(String::as_str), Some("true"));
1286 : // the false reading is carried through unchanged, never as "true"
1287 4 : let q = json!({"type": "Query", "splitEntities": false});
1288 4 : let mut vp = HashMap::new();
1289 4 : query_doc_params(q.as_object().expect("object"), false, &mut vp).expect("valid Query");
1290 4 : assert_eq!(vp.get("splitEntities").map(String::as_str), Some("false"));
1291 4 : }
1292 :
1293 : /// 5.2.33: the `entities` EntitySelectors are a union and "id takes
1294 : /// precedence over idPattern" PER selector. A flat filter carries one
1295 : /// id/idPattern pair, so a member may only be emitted when it holds for
1296 : /// every selector — otherwise the flat filter excludes Entities that a
1297 : /// selector on its own selects.
1298 : #[test]
1299 4 : fn entity_selectors_are_a_union_not_a_flat_id_filter() {
1300 4 : let mixed = json!({"type": "Query", "entities": [
1301 4 : {"type": "Vehicle", "id": "urn:ngsi-ld:Vehicle:1"},
1302 4 : {"type": "Building", "idPattern": "^urn:ngsi-ld:Building:"}
1303 : ]});
1304 4 : let mut vp = HashMap::new();
1305 4 : query_doc_params(mixed.as_object().expect("object"), false, &mut vp).expect("valid Query");
1306 4 : assert_eq!(vp.get("type").map(String::as_str), Some("Vehicle,Building"));
1307 4 : assert!(
1308 4 : !vp.contains_key("id"),
1309 : "an id from one selector must not filter the other selector out: {vp:?}"
1310 : );
1311 4 : assert!(
1312 4 : !vp.contains_key("idPattern"),
1313 : "a pattern from one selector must not filter the other out: {vp:?}"
1314 : );
1315 : // every selector carries id → the union of ids is exact
1316 4 : let all_ids = json!({"type": "Query", "entities": [
1317 4 : {"type": "Vehicle", "id": "urn:ngsi-ld:Vehicle:1"},
1318 4 : {"type": "Building", "id": ["urn:ngsi-ld:Building:1"]}
1319 : ]});
1320 4 : let mut vp = HashMap::new();
1321 4 : query_doc_params(all_ids.as_object().expect("object"), false, &mut vp)
1322 4 : .expect("valid Query");
1323 4 : assert_eq!(
1324 4 : vp.get("id").map(String::as_str),
1325 : Some("urn:ngsi-ld:Vehicle:1,urn:ngsi-ld:Building:1")
1326 : );
1327 4 : assert!(!vp.contains_key("idPattern"));
1328 : // no selector carries id and every one carries idPattern → union
1329 4 : let all_pats = json!({"type": "Query", "entities": [
1330 4 : {"type": "Vehicle", "idPattern": "^urn:ngsi-ld:Vehicle:"},
1331 4 : {"type": "Building", "idPattern": "^urn:ngsi-ld:Building:"}
1332 : ]});
1333 4 : let mut vp = HashMap::new();
1334 4 : query_doc_params(all_pats.as_object().expect("object"), false, &mut vp)
1335 4 : .expect("valid Query");
1336 4 : assert_eq!(
1337 4 : vp.get("idPattern").map(String::as_str),
1338 : Some("^urn:ngsi-ld:Vehicle:|^urn:ngsi-ld:Building:")
1339 : );
1340 4 : assert!(!vp.contains_key("id"));
1341 : // a single selector keeps the 5.2.33 id-over-idPattern precedence
1342 4 : let both = json!({"type": "Query", "entities": [
1343 4 : {"type": "Vehicle", "id": "urn:ngsi-ld:Vehicle:1", "idPattern": "^urn:"}
1344 : ]});
1345 4 : let mut vp = HashMap::new();
1346 4 : query_doc_params(both.as_object().expect("object"), false, &mut vp).expect("valid Query");
1347 4 : assert_eq!(
1348 4 : vp.get("id").map(String::as_str),
1349 : Some("urn:ngsi-ld:Vehicle:1")
1350 : );
1351 4 : assert!(!vp.contains_key("idPattern"), "id wins in one selector");
1352 4 : }
1353 :
1354 : /// RFC 3986 clause 3.4 + RFC 8288 clause 3: a value spliced back into a
1355 : /// query string must not be able to start another parameter, decode a
1356 : /// second time, or end the link-value it is carried in. The characters an
1357 : /// NGSI-LD filter legitimately uses (`urn:`, `.`, `*`, `-`) survive, or
1358 : /// every pagination link would run a different query than the one that
1359 : /// produced it.
1360 : #[test]
1361 4 : fn query_value_encodes_what_would_change_the_query() {
1362 4 : assert_eq!(
1363 4 : query_value("urn:ngsi-ld:Building:01931.*"),
1364 : "urn:ngsi-ld:Building:01931.*"
1365 : );
1366 4 : assert_eq!(query_value("cat>1"), "cat%3E1");
1367 4 : assert_eq!(query_value(r#"cat=="a&b""#), "cat%3D%3D%22a%26b%22");
1368 : // a value already carrying a percent must not decode twice
1369 4 : assert_eq!(query_value("a%26b"), "a%2526b");
1370 : // `+` reads back as a space in a query string
1371 4 : assert_eq!(query_value("a+b"), "a%2Bb");
1372 4 : assert_eq!(query_value("é"), "%C3%A9");
1373 4 : }
1374 : }
|